Compare commits

...

2 Commits

Author SHA1 Message Date
Andy 102778482b Version 2.7.4 (#1040)
* ci: add Azure auth test workflow

* fix(worktree): handle "already up to date" case correctly (ACS-226) (#961)

* fix(worktree): handle "already up to date" case correctly (ACS-226)

When git merge returns non-zero for "Already up to date", the merge
code incorrectly treated this as a conflict and aborted. Now checks
git output to distinguish between:
- "Already up to date" - treat as success (nothing to merge)
- Actual conflicts - abort as before
- Other errors - show actual error message

Also added comprehensive tests for edge cases:
- Already up to date with no_commit=True
- Already up to date with delete_after=True
- Actual merge conflict detection
- Merge conflict with no_commit=True

* test: strengthen merge conflict abort verification

Improve assertions in conflict detection tests to explicitly verify:
- MERGE_HEAD does not exist after merge abort
- git status returns clean (no staged/unstaged changes)

This is more robust than just checking for absence of "CONFLICT"
string, as git status --porcelain uses status codes, not literal words.

* test: add git command success assertions and branch deletion verification

- Add explicit returncode assertions for all subprocess.run git add/commit calls
- Add branch deletion verification in test_merge_worktree_already_up_to_date_with_delete_after
- Ensures tests fail early if git commands fail rather than continuing silently

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>

* fix(terminal): add collision detection for terminal drag and drop reordering (#985)

* fix(terminal): add collision detection for terminal drag and drop reordering

Add closestCenter collision detection to DndContext to fix terminal
drag and drop swapping not detecting valid drop targets. The default
rectIntersection algorithm required too much overlap for grid layouts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): handle file drops when closestCenter returns sortable ID

Address PR review feedback:
- Fix file drop handling to work when closestCenter collision detection
  returns the sortable ID instead of the droppable ID
- Add terminals to useCallback dependency array to prevent stale state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ACS-181): enable auto-switch on 401 auth errors & OAuth-only profiles (#900)

* fix(ACS-181): enable auto-switch for OAuth-only profiles

Add OAuth token check at the start of isProfileAuthenticated() so that
profiles with only an oauthToken (no configDir) are recognized as
authenticated. This allows the profile scorer to consider OAuth-only
profiles as valid alternatives for proactive auto-switching.

Previously, isProfileAuthenticated() immediately returned false if
configDir was missing, causing OAuth-only profiles to receive a -500
penalty in the scorer and never be selected for auto-switch.

Fixes: ACS-181

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Black Circle Sentinel <mludlow000@icloud.com>

* fix(ACS-181): detect 'out of extra usage' rate limit messages

The previous patterns only matched "Limit reached · resets ..." but
Claude Code also shows "You're out of extra usage · resets ..." which
wasn't being detected. This prevented auto-switch from triggering.

Added new patterns to both output-parser.ts (terminal) and
rate-limit-detector.ts (agent processes) to detect:
- "out of extra usage · resets ..."
- "You're out of extra usage · resets ..."

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ACS-181): add real-time rate limit detection and debug logging

- Add real-time rate limit detection in agent-process.ts processLog()
  so rate limits are detected immediately as output appears, not just
  when the process exits
- Add clear warning message when auto-switch is disabled in settings
- Add debug logging to profile-scorer.ts to trace profile evaluation
- Add debug logging to rate-limit-detector.ts to trace pattern matching

This enables immediate detection and auto-switch when rate limits occur
during task execution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): enable auto-switch on 401 auth errors

- Propagate 401/403 errors from fetchUsageViaAPI to checkUsageAndSwap in UsageMonitor to trigger proactive profile swapping.
- Fix usage monitor race condition by ensuring it waits for ClaudeProfileManager initialization.
- Fix isProfileAuthenticated to correctly validate OAuth-only profiles.

* fix(ACS-181): address PR review feedback

- Revert unrelated files (rate-limit-detector, output-parser, agent-process) to upstream state
- Gate profile-scorer logging behind DEBUG flag
- Fix usage-monitor type safety and correct catch block syntax
- Fix misleading indentation in index.ts app updater block

* fix(frontend): enforce eslint compliance for logs in profile-scorer

- Replace all console.log with console.warn (per linter rules)
- Strictly gate all debug logs behind isDebug check to prevent production noise

* fix(ACS-181): add swap loop protection for auth failures

- Add authFailedProfiles Map to track profiles with recent auth failures
- Implement 5-minute cooldown before retrying failed profiles
- Exclude failed profiles from swap candidates to prevent infinite loops
- Gate TRACE logs behind DEBUG flag to reduce production noise
- Change console.log to console.warn for ESLint compliance

---------

Signed-off-by: Black Circle Sentinel <mludlow000@icloud.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(frontend): add Claude Code version rollback feature (#983)

* feat(frontend): add Claude Code version rollback feature

Add ability for users to switch to any of the last 20 Claude Code CLI versions
directly from the Claude Code popup in the sidebar.

Changes:
- Add IPC channels for fetching available versions and installing specific version
- Add backend handlers to fetch versions from npm registry (with 1-hour cache)
- Add version selector dropdown in ClaudeCodeStatusBadge component
- Add warning dialog before switching versions (warns about closing sessions)
- Add i18n support for English and French translations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback for Claude Code version rollback

- Add validation after semver filtering to handle empty version list
- Add error state and UI feedback for installation/version switch failures
- Extract magic number 5000ms to VERSION_RECHECK_DELAY_MS constant
- Bind Select value prop to selectedVersion state
- Normalize version comparison to handle 'v' prefix consistently
- Use normalized version comparison in SelectItem disabled check

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): inherit security profiles in worktrees and validate shell -c commands (#971)

* fix(security): inherit security profiles in worktrees and validate shell -c commands

- Add inherited_from field to SecurityProfile to mark profiles copied from parent projects
- Skip hash-based re-analysis for inherited profiles (fixes worktrees losing npm/npx etc.)
- Add shell_validators.py to validate commands inside bash/sh/zsh -c strings
- Register shell validators to close security bypass via bash -c "arbitrary_command"
- Add 13 new tests for inherited profiles and shell -c validation

Fixes worktree security config not being inherited, which caused agents to be
blocked from running npm/npx commands in isolated workspaces.

* docs: update README download links to v2.7.3 (#976)

- Update all stable download links from 2.7.2 to 2.7.3
- Add Flatpak download link (new in 2.7.3)

* fix(security): close shell -c bypass vectors and validate inherited profiles

- Fix combined shell flags bypass (-xc, -ec, -ic) in _extract_c_argument()
  Shell allows combining flags like `bash -xc 'cmd'` which bypassed -c detection
- Add recursive validation for nested shell invocations
  Prevents bypass via `bash -c "bash -c 'evil_cmd'"`
- Validate inherited_from path in should_reanalyze() with defense-in-depth
  - Must exist and be a directory
  - Must be an ancestor of current project
  - Must contain valid security profile
- Add comprehensive test coverage for all security fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix import ordering in test_security.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: format shell_validators.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(frontend): add searchable branch combobox to worktree creation dialog (#979)

* feat(frontend): add searchable branch combobox to worktree creation dialog

- Replace limited Select dropdown with searchable Combobox for branch selection
- Add new Combobox UI component with search filtering and scroll support
- Remove 15-branch limit - now shows all branches with search
- Improve worktree name validation to allow dots and underscores
- Better sanitization: spaces become hyphens, preserve valid characters
- Add i18n keys for branch search UI in English and French

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): address PR review feedback for worktree dialog

- Extract sanitizeWorktreeName utility function to avoid duplication
- Replace invalid chars with hyphens instead of removing them (feat/new → feat-new)
- Trim trailing hyphens and dots from sanitized names
- Add validation to forbid '..' in names (invalid for Git branch names)
- Refactor branchOptions to use map/spread instead of forEach/push
- Add ARIA accessibility: listboxId, aria-controls, role="listbox"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): align worktree name validation with backend regex

- Fix frontend validation to match backend WORKTREE_NAME_REGEX (no dots,
  must end with alphanumeric)
- Update sanitizeWorktreeName to exclude dots from allowed characters
- Update i18n messages (en/fr) to remove mention of dots
- Add displayName to Combobox component for React DevTools
- Export Combobox from UI component index.ts
- Add aria-label to Combobox listbox for accessibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): address PR review accessibility and cleanup issues

- Add forwardRef pattern to Combobox for consistency with other UI components
- Add keyboard navigation (ArrowUp/Down, Enter, Escape, Home, End)
- Add aria-activedescendant for screen reader focus tracking
- Add unique option IDs for ARIA compliance
- Add cleanup for async branch fetching to prevent state updates on unmounted component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): sync worktree config to renderer on terminal restoration (#982)

* fix(frontend): sync worktree config to renderer on terminal restoration

When terminals are restored after app restart, the worktree config
was not being synced to the renderer, causing the worktree label
to not appear. This adds a new IPC channel to send worktree config
during restoration and a listener in useTerminalEvents to update
the terminal store.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): always sync worktreeConfig to handle deleted worktrees

Addresses PR review feedback: send worktreeConfig IPC message
unconditionally so the renderer can clear stale worktree labels
when a worktree is deleted while the app is closed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix(merge): include files with content changes even when semantic analysis is empty (#986)

* fix(merge): include files with content changes even when semantic analysis is empty

The merge system was discarding files that had real code changes but no
detected semantic changes. This happened because:

1. The semantic analyzer only detects imports and function additions/removals
2. Files with only function body modifications returned semantic_changes=[]
3. The filter used Python truthiness (empty list = False), excluding these files
4. This caused merges to fail with "0 files to merge" despite real changes

The fix uses content hash comparison as a fallback check. If the file content
actually changed (hash_before != hash_after), include it for merge regardless
of whether the semantic analyzer could parse the specific change types.

This fixes merging for:
- Files with function body modifications (most common case)
- Unsupported file types (Rust, Go, etc.) where semantic analysis returns empty
- Any file where the analyzer fails to detect the specific change pattern

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(merge): add TaskSnapshot.has_modifications property and handle DIRECT_COPY

Address PR review feedback:

1. DRY improvement: Add `has_modifications` property to TaskSnapshot
   - Centralizes the modification detection logic
   - Checks semantic_changes first, falls back to content hash comparison
   - Handles both complete tasks and in-progress tasks safely

2. Fix for files with empty semantic_changes (Cursor issue #2):
   - Add DIRECT_COPY MergeDecision for files that were modified but
     couldn't be semantically analyzed (body changes, unsupported languages)
   - MergePipeline returns DIRECT_COPY when has_modifications=True but
     semantic_changes=[] (single task case)
   - Orchestrator handles DIRECT_COPY by reading file directly from worktree
   - This prevents silent data loss where apply_single_task_changes would
     return baseline content unchanged

3. Update _update_stats to count DIRECT_COPY as auto-merged

The combination ensures:
- Files ARE detected for merge (has_modifications check)
- Files ARE properly merged (DIRECT_COPY reads from worktree)
- No silent data loss (worktree content used instead of baseline)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(merge): handle DIRECT_COPY in merge_tasks() and log missing files

- Add DIRECT_COPY handling to merge_tasks() for multi-task merges
  (was only handled in merge_task() for single-task merges)
- Add warning logging when worktree file doesn't exist during DIRECT_COPY
  in both merge_task() and merge_tasks()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(merge): remove unnecessary f-string prefixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(merge): properly fail DIRECT_COPY when worktree file missing

- Extract _read_worktree_file_for_direct_copy() helper to DRY up logic
- Set decision to FAILED when worktree file not found (was silent success)
- Add warning when worktree_path is None in merge_tasks
- Use `is not None` check for merged_content to allow empty files
- Fix has_modifications for new files with empty hash_before
- Add debug_error() to merge_tasks exception handling for consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style(merge): fix ruff formatting for long line

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): detect Claude exit and reset label when user closes Claude (#990)

* fix(terminal): detect Claude exit and reset label when user closes Claude

Previously, the "Claude" label on terminals would persist even after the
user closed Claude (via /exit, Ctrl+D, etc.) because the system only
reset isClaudeMode when the entire terminal process exited.

This change adds robust Claude exit detection by:
- Adding shell prompt patterns to detect when Claude exits and returns
  to shell (output-parser.ts)
- Adding new IPC channel TERMINAL_CLAUDE_EXIT for exit notifications
- Adding handleClaudeExit() to reset terminal state in main process
- Adding onClaudeExit callback in terminal event handler
- Adding onTerminalClaudeExit listener in preload API
- Handling exit event in renderer to update terminal store

Now when a user closes Claude within a terminal, the label is removed
immediately while the terminal continues running.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): add line-start anchors to exit detection regex patterns

Address PR review findings:
- Add ^ anchors to CLAUDE_EXIT_PATTERNS to prevent false positive exit
  detection when Claude outputs paths, array access, or Unicode arrows
- Add comprehensive unit tests for detectClaudeExit and related functions
- Remove duplicate debugLog call in handleClaudeExit (keep console.warn)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): prevent false exit detection for emails and race condition

- Update user@host regex to require path indicator after colon,
  preventing emails like user@example.com: from triggering exit detection
- Add test cases for emails at line start to ensure they don't match
- Add guard in onTerminalClaudeExit to prevent setting status to 'running'
  if terminal has already exited (fixes potential race condition)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix(app-update): persist downloaded update state for Install button visibility (#992)

* fix(app-update): persist downloaded update state for Install button visibility

When updates auto-download in background, users miss the update-downloaded
event if not on Settings page. This causes "Install and Restart" button
to never appear.

Changes:
- Add downloadedUpdateInfo state in app-updater.ts to persist downloaded info
- Add APP_UPDATE_GET_DOWNLOADED IPC handler to query downloaded state
- Add getDownloadedAppUpdate API method in preload
- Update AdvancedSettings to check for already-downloaded updates on mount

Now when user opens Settings after background download, the component
queries persisted state and shows "Install and Restart" correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(app-update): resolve race condition and type safety issues

- Fix race condition where checkForAppUpdates() could overwrite downloaded
  update info with null, causing 'Unknown' version display
- Add proper type guard for releaseNotes (can be string | array | null)
  instead of unsafe type assertion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(app-update): clear downloaded update state on channel change and add useEffect cleanup

- Clear downloadedUpdateInfo when update channel changes to prevent showing
  Install button for updates from a different channel (e.g., beta update
  showing after switching to stable channel)
- Add isCancelled flag to useEffect async operations in AdvancedSettings
  to prevent React state updates on unmounted components

Addresses CodeRabbit review findings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix(backend): add Sentry integration and fix broken pipe errors (#991)

* fix(backend): add Sentry integration and fix broken pipe errors

- Add sentry-sdk to Python backend for error tracking
- Create safe_print() utility to handle BrokenPipeError gracefully
- Initialize Sentry in CLI, GitHub runner, and spec runner entry points
- Use same SENTRY_DSN environment variable as Electron frontend
- Apply privacy path masking (usernames removed from stack traces)

Fixes "Review Failed: [Errno 32] Broken pipe" error in PR review

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(backend): address PR review findings for Sentry integration

- Fix ruff linting errors (unused imports, import sorting)
- Add path masking to set_context() and set_tag() for privacy
- Add defensive path masking to capture_exception() kwargs
- Add debug logging for bare except clauses in sentry.py
- Add top-level error handler in cli/main.py with Sentry capture
- Add error handling with Sentry capture in spec_runner.py
- Move safe_print to core/io_utils.py for broader reuse
- Migrate GitLab runner files to use safe_print()
- Add fallback import pattern in sdk_utils.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: apply ruff formatting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(backend): address CodeRabbit review findings for Sentry and io_utils

- Add path masking to capture_message() kwargs for privacy consistency
- Add recursion depth limit (50) to _mask_object_paths() to prevent stack overflow
- Add WSL path masking support (/mnt/[a-z]/Users/...)
- Add consistent ImportError debug logging across Sentry wrapper functions
- Add ValueError handling in safe_print() for closed stdout scenarios
- Improve reset_pipe_state() documentation with usage warnings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix: improve Claude CLI detection and add installation selector (#1004)

* fix: improve Claude CLI detection and add installation selector

This PR addresses the "Claude Code not found" error when starting tasks by
improving CLI path detection across all platforms.

Backend changes:
- Add cross-platform `find_claude_cli()` function in `client.py` that checks:
  - CLAUDE_CLI_PATH environment variable for user override
  - System PATH via shutil.which()
  - Homebrew paths on macOS
  - NVM paths for Node.js version manager installations
  - Platform-specific standard locations (Windows: AppData, Program Files; Unix: .local/bin)
- Pass detected `cli_path` to ClaudeAgentOptions in both `create_client()` and `create_simple_client()`
- Improve Windows .cmd/.bat file execution using proper cmd.exe flags (/d, /s, /c)
  and correct quoting for paths with spaces

Frontend changes:
- Add IPC handlers for scanning all Claude CLI installations and switching active path
- Update ClaudeCodeStatusBadge to show current CLI path and allow selection when
  multiple installations are detected
- Add `writeSettingsFile()` to settings-utils for persisting CLI path selection
- Add translation keys for new UI elements (English and French)

Closes #1001

* fix: address PR review findings for Claude CLI detection

Addresses all 8 findings from Auto Claude PR Review:

Security improvements:
- Add path sanitization (_is_secure_path) to backend CLI validation
  to prevent command injection via malicious paths
- Add isSecurePath validation in frontend IPC handler before CLI execution
- Normalize paths with path.resolve() before execution

Architecture improvements:
- Refactor scanClaudeInstallations to use getClaudeDetectionPaths() from
  cli-tool-manager.ts as single source of truth (addresses code duplication)
- Add cross-reference comments between backend _get_claude_detection_paths()
  and frontend getClaudeDetectionPaths() to keep them in sync

Bug fixes:
- Fix path display truncation to use regex /[/\\]/ for cross-platform
  compatibility (Windows uses backslashes)
- Add null check for version in UI rendering (shows "version unknown"
  instead of "vnull")
- Use DEFAULT_APP_SETTINGS merge pattern for settings persistence

Debugging improvements:
- Add error logging in validateClaudeCliAsync catch block for better
  debugging of CLI detection issues

Translation additions:
- Add "versionUnknown" key to English and French navigation.json

* ci(release): move VirusTotal scan to separate post-release workflow (#980)

* ci(release): move VirusTotal scan to separate post-release workflow

VirusTotal scans were blocking release creation, taking 5+ minutes per
file. This change moves the scan to a separate workflow that triggers
after the release is published, allowing releases to be available
immediately.

- Create virustotal-scan.yml workflow triggered on release:published
- Remove blocking VirusTotal step from release.yml
- Scan results are appended to release notes after completion
- Add manual trigger option for rescanning old releases

* fix(ci): address PR review issues in VirusTotal scan workflow

- Add error checking on gh release view to prevent wiping release notes
- Replace || true with proper error handling to distinguish "no assets" from real errors
- Use file-based approach for release notes to avoid shell expansion issues
- Use env var pattern consistently for secret handling
- Remove placeholder text before appending VT results
- Document 32MB threshold with named constant
- Add HTTP status code validation on all curl requests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): add concurrency control and remove dead code in VirusTotal workflow

- Add concurrency group to prevent TOCTOU race condition when multiple
  workflow_dispatch runs target the same release tag
- Remove unused analysis_failed variable declaration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): improve error handling in VirusTotal workflow

- Fail workflow when download errors occur but scannable assets exist
- Add explicit timeout handling for analysis polling loop
- Use portable sed approach (works on both GNU and BSD sed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): display actual base branch name instead of hardcoded main (#969)

* fix(ui): display actual base branch name instead of hardcoded "main"

The merge conflict UI was showing "Main branch has X new commits"
regardless of the actual base branch. Now it correctly displays
the dynamic branch name (e.g., "develop branch has 40 new commits")
using the baseBranch value from gitConflicts.

* docs: update README download links to v2.7.3 (#976)

- Update all stable download links from 2.7.2 to 2.7.3
- Add Flatpak download link (new in 2.7.3)

* fix(i18n): add translation keys for branch divergence messages

- Add merge section to taskReview.json with pluralized translations
- Update WorkspaceStatus.tsx to use i18n for branch behind message
- Update MergePreviewSummary.tsx to use i18n for branch divergence text
- Add French translations for all new keys

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(i18n): add missing translation keys for branch behind details

- Add branchHasNewCommitsSinceBuild for build started message
- Add filesNeedAIMergeDueToRenames for path-mapped files
- Add fileRenamesDetected for rename detection message
- Add filesRenamedOrMoved for generic rename/move message
- Update WorkspaceStatus.tsx to use all new i18n keys

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(i18n): correct pluralization for rename count in AI merge message

The filesNeedAIMergeDueToRenames translation has two values that need
independent pluralization (fileCount and renameCount). Since i18next
only supports one count parameter, added separate translation keys
for singular/plural renames and select the correct key based on
renameCount value.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(i18n): use translation keys for merge button labels with dynamic branch

Replace hardcoded 'Stage to Main' and 'Merge to Main' button labels with
i18n translation keys that interpolate the actual target branch name.
Also adds translations for loading states (Resolving, Staging, Merging).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github-prs): prevent preloading of PRs currently under review (#1006)

- Updated logic to skip PRs that are currently being reviewed when determining which PRs need preloading.
- Enhanced condition to only fetch existing review data from disk if no review is in progress, ensuring that ongoing reviews are not overwritten by stale data.

* chore: bump version to 2.7.4

* hotfix/sentry-backend-build

* fix(github): resolve circular import issues in context_gatherer and services (#1026)

- Updated import statements in context_gatherer.py to import safe_print from core.io_utils to avoid circular dependencies with the services package.
- Introduced lazy imports in services/__init__.py to prevent circular import issues, detailing the import chain in comments for clarity.
- Added a lazy import handler to load classes on first access, improving module loading efficiency.

* feat(sentry): embed Sentry DSN at build time for packaged apps (#1025)

* feat(sentry): integrate Sentry configuration into Electron build

- Added build-time constants for Sentry DSN and sampling rates in electron.vite.config.ts.
- Enhanced environment variable handling in env-utils.ts to include Sentry settings for subprocesses.
- Implemented getSentryEnvForSubprocess function in sentry.ts to provide Sentry environment variables for Python backends.
- Updated Sentry-related functions to prioritize build-time constants over runtime environment variables for improved reliability.

This integration ensures that Sentry is properly configured for both local development and CI environments.

* fix(sentry): add typeof guards for build-time constants in tests

The __SENTRY_*__ constants are only defined when Vite's define plugin runs
during build. In test environments (vitest), these constants are undefined
and cause ReferenceError. Added typeof guards to safely handle both cases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* Fix Duplicate Kanban Task Creation on Rapid Button Clicks (#1021)

* auto-claude: subtask-1-1 - Add convertingIdeas state and guard logic to useIdeation hook

* auto-claude: subtask-1-2 - Update IdeaDetailPanel to accept isConverting prop

* auto-claude: subtask-2-1 - Add idempotency check for linked_task_id in task-c

* auto-claude: subtask-3-1 - Manual testing: Verify rapid clicking creates only one task

- Fixed missing convertingIdeas prop connection in Ideation.tsx
- Added convertingIdeas to destructured hook values
- Added isConverting prop to IdeaDetailPanel component
- Created detailed manual-test-report.md with code review and E2E testing instructions
- All code implementation verified via TypeScript checks (no errors)
- Multi-layer protection confirmed: UI disabled, guard check, backend idempotency
- Manual E2E testing required for final verification

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: address PR review findings for duplicate task prevention

- Fix TOCTOU race condition by moving idempotency check inside lock
- Fix React state closure by using ref for synchronous tracking
- Add i18n translations for ideation UI (EN + FR)
- Add error handling with toast notifications for conversion failures

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(terminal): add YOLO mode to invoke Claude with --dangerously-skip-permissions (#1016)

* feat(terminal): add YOLO mode to invoke Claude with --dangerously-skip-permissions

Add a toggle in Developer Tools settings that enables "YOLO Mode" which
starts Claude with the --dangerously-skip-permissions flag, bypassing
all safety prompts.

Changes:
- Add dangerouslySkipPermissions setting to AppSettings interface
- Add translation keys for YOLO mode (en/fr)
- Modify claude-integration-handler to accept and append extra flags
- Update terminal-manager and terminal-handlers to read and forward the setting
- Add Switch toggle with warning styling in DevToolsSettings UI

The toggle includes visual warnings (amber colors, AlertTriangle icon) to
clearly indicate this is a dangerous option that bypasses Claude's
permission system.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): address PR review issues for YOLO mode implementation

- Add async readSettingsFileAsync to avoid blocking main process during settings read
- Extract YOLO_MODE_FLAG constant to eliminate duplicate flag strings
- Store dangerouslySkipPermissions on terminal object to persist YOLO mode across profile switches
- Update switchClaudeProfile callback to pass stored YOLO mode setting

These fixes address:
- LOW: Synchronous file I/O in IPC handler
- LOW: Flag string duplicated in invokeClaude and invokeClaudeAsync
- MEDIUM: YOLO mode not persisting when switching Claude profiles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* Make worktree isolation prominent in UI (#1020)

* auto-claude: subtask-1-1 - Add i18n translation keys for worktree notice banner and merge tooltip

- Added wizard.worktreeNotice.title and wizard.worktreeNotice.description for task creation banner
- Added review.mergeTooltip for merge button explanation
- Translations added to both en/tasks.json and fr/tasks.json

* auto-claude: subtask-1-2 - Add visible info banner to TaskCreationWizard expl

* auto-claude: subtask-1-3 - Add tooltip to 'Merge with AI' button in WorkspaceStatus

- Import Tooltip components from ui/tooltip
- Wrap merge button with Tooltip, TooltipTrigger, TooltipContent
- Add contextual tooltip text explaining merge operation:
  * With AI: explains worktree merge, removal, and AI conflict resolution
  * Without AI: explains worktree merge and removal
- Follows Radix UI tooltip pattern from reference file

* fix: use i18n key for merge button tooltip in WorkspaceStatus

* fix: clarify merge tooltip - worktree removal is optional (qa-requested)

Fixes misleading tooltip text that implied worktree is automatically removed
during merge. In reality, after merge, users are shown a dialog where they can
choose to keep or remove the worktree. Updated tooltip to reflect this flow.

Changes:
- Updated en/tasks.json: Changed tooltip to clarify worktree removal is optional
- Updated fr/tasks.json: Updated French translation to match

QA Feedback: "Its currently saying on the tooltip that it will 'remove the worktree'
Please validate if this is the actual logic. As per my understanding, there will be
an extra button afterwards that will make sure that the user has access to the work
tree if they want to revert anything. The user has to manually accept to remove the
work tree."

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: use theme-aware colors for worktree info banner

Replace hardcoded blue colors with semantic theme classes to support
dark mode properly. Uses the same pattern as other info banners in
the codebase (bg-info/10, border-info/30, text-info).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): improve worktree name input UX (#1012)

* fix(terminal): improve worktree name input to not strip trailing characters while typing

- Allow trailing hyphens/underscores during input (only trim on submit)
- Add preview name that shows the final sanitized value for branch preview
- Remove invalid characters instead of replacing with hyphens
- Collapse consecutive underscores in addition to hyphens
- Final sanitization happens on submit to match backend WORKTREE_NAME_REGEX

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): address PR review findings for worktree name validation

- Fix submit button disabled check to use sanitized name instead of raw input
- Simplify trailing trim logic (apply once after all transformations)
- Apply lowercase in handleNameChange to reduce input/preview gap
- Internationalize 'name' fallback using existing translation key

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): improve header responsiveness for multiple terminals

- Hide text labels (Claude, Open in IDE) when ≥4 terminals, show icon only
- Add dynamic max-width to worktree name badge with truncation
- Add tooltips to all icon-only elements for accessibility
- Maintain full functionality while reducing header width requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>

* fix(terminal): enhance terminal recreation logic with retry mechanism (#1013)

* fix(terminal): enhance terminal recreation logic with retry mechanism

- Introduced a maximum retry limit and delay for terminal recreation when dimensions are not ready.
- Added cleanup for retry timers on component unmount to prevent memory leaks.
- Improved error handling to report failures after exceeding retry attempts, ensuring better user feedback during terminal setup.

* fix(terminal): address PR review feedback for retry mechanism

- Fix race condition: clear pending retry timer at START of effect
  to prevent multiple timers when dependencies change mid-retry
- Fix isCreatingRef: keep it true during retry window to prevent
  duplicate creation attempts from concurrent effect runs
- Extract duplicated retry logic into scheduleRetryOrFail helper
  (consolidated 5 duplicate instances into 1 reusable function)
- Add handleSuccess/handleError helpers to reduce code duplication
- Reduce file from 295 to 237 lines (~20% reduction)

Addresses review feedback from CodeRabbit, Gemini, and Auto Claude.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(terminal): add task worktrees section and remove terminal limit (#1033)

* feat(terminal): add task worktrees section and remove terminal limit

- Remove 12 terminal worktree limit (now unlimited)
- Add "Task Worktrees" section in worktree dropdown below terminal worktrees
- Task worktrees (created by kanban) now accessible for manual work
- Update translations for new section labels (EN + FR)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): address PR review feedback

- Clear taskWorktrees state when project is null or changes
- Parallelize API calls with Promise.all for better performance
- Use consistent path-based filtering for both worktree types
- Add clarifying comment for createdAt timestamp

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>

* Add file/screenshot upload to QA feedback interface (#1018)

* auto-claude: subtask-1-1 - Add feedbackImages state and handlers to useTaskDetail

- Add feedbackImages state as ImageAttachment[] for storing feedback images
- Add setFeedbackImages setter for direct state updates
- Add addFeedbackImage handler for adding a single image
- Add addFeedbackImages handler for adding multiple images at once
- Add removeFeedbackImage handler for removing an image by ID
- Add clearFeedbackImages handler for clearing all images
- Import ImageAttachment type from shared/types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-2 - Update IPC interface to support images in submitReview

- Add ImageAttachment import from ./task types
- Update submitReview signature to include optional images parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-3 - Update submitReview function in task-store to accept and pass images

* auto-claude: subtask-2-1 - Add paste/drop handlers and image thumbnail displa

- Add paste event handler for screenshot/image clipboard support
- Add drag-over and drag-leave handlers for visual feedback during drag
- Add drop handler for image file drops
- Add image thumbnail display (64x64) with remove button on hover
- Import image utilities from ImageUpload.tsx (generateImageId, blobToBase64, etc.)
- Add i18n support for all new UI text
- Make new props optional for backward compatibility during incremental rollout
- Allow submission with either text feedback or images (not both required)
- Add visual drag feedback with border/background color change

* auto-claude: subtask-2-2 - Update TaskReview to pass image props to QAFeedbackSection

* auto-claude: subtask-2-3 - Update TaskDetailModal to manage image state and pass to TaskReview

- Pass feedbackImages and setFeedbackImages from useTaskDetail hook to TaskReview
- Update handleReject to include images in submitReview call
- Allow submission with images only (no text required)
- Clear images after successful submission

* auto-claude: subtask-3-1 - Add English translations for feedback image UI

* auto-claude: subtask-3-2 - Add French translations for feedback image UI

* fix(security): sanitize image filename to prevent path traversal

- Use path.basename() to strip directory components from filenames
- Validate sanitized filename is not empty, '.', or '..'
- Add defense-in-depth check verifying resolved path stays within target directory
- Fix base64 data URL regex to handle complex MIME types (e.g., svg+xml)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add MIME type validation and fix SVG file extension

- Add server-side MIME type validation for image uploads (defense in depth)
- Fix SVG file extension: map 'image/svg+xml' to '.svg' instead of '.svg+xml'
- Add MIME-to-extension mapping for all allowed image types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: require mimeType and apply SVG extension fix to drop handler

- Change MIME validation to reject missing mimeType (prevents bypass)
- Add 'image/jpg' to server-side allowlist for consistency
- Apply mimeToExtension mapping to drop handler (was only in paste handler)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>

* fix(auth): await profile manager initialization before auth check (#1010)

* fix(auth): await profile manager initialization before auth check

Fixes race condition where hasValidAuth() was called before the
ClaudeProfileManager finished async initialization from disk.

The getClaudeProfileManager() returns a singleton immediately with
default profile data (no OAuth token). When hasValidAuth() runs
before initialization completes, it returns false even when valid
credentials exist.

Changed all pre-flight auth checks to use
await initializeClaudeProfileManager() which ensures initialization
completes via promise caching.

Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com>

* fix(auth): add error handling for profile manager initialization

Prevents unhandled promise rejections when initializeClaudeProfileManager()
throws due to filesystem errors (permissions, disk full, corrupt JSON).

The ipcMain.on handler for TASK_START doesn't await promises, so
unhandled rejections could crash the main process. Wrapped all
await initializeClaudeProfileManager() calls in try-catch blocks.

Found via automated code review.

Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com>

* test: mock initializeClaudeProfileManager in subprocess tests

The test mock was only mocking getClaudeProfileManager, but now we
also use initializeClaudeProfileManager which wasn't mocked, causing
test failures.

Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com>

* fix(auth): add try-catch for initializeClaudeProfileManager in remaining handlers

Addresses PR review feedback - TASK_UPDATE_STATUS and TASK_RECOVER_STUCK
handlers were missing try-catch blocks for initializeClaudeProfileManager(),
inconsistent with TASK_START handler.

If initialization fails, users now get specific file permissions guidance
instead of generic error messages.

Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com>

* refactor(auth): extract profile manager initialization into helper

Extract the repeated initializeClaudeProfileManager() + try/catch pattern
into a helper function ensureProfileManagerInitialized() that returns
a discriminated union for type-safe error handling.

This reduces code duplication across TASK_START, TASK_UPDATE_STATUS,
and TASK_RECOVER_STUCK handlers while preserving context-specific
error handling behavior.

The helper returns:
- { success: true, profileManager } on success
- { success: false, error } on failure

Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com>

* fix(auth): improve error details and allow retry after transient failures

Two improvements to profile manager initialization:

1. Include actual error details in failure response for better debugging.
   Previously, only a generic message was returned to users, making it
   hard to diagnose the root cause. Now the error message is appended.

2. Reset cached promise on failure to allow retries after transient errors.
   Previously, if initialize() failed (e.g., EACCES, ENOSPC), the rejected
   promise was cached forever, requiring app restart to recover. Now the
   cached promise is reset on failure, allowing subsequent calls to retry.

Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com>

---------

Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>

* fix(frontend): validate Windows claude.cmd reliably in GUI (#1023)

* fix: use absolute cmd.exe for Claude CLI validation

* fix: make cmd.exe validation type-safe for tests

* fix: satisfy frontend typecheck for cli tool tests

Signed-off-by: Umaru <caleb.1331@outlook.com>

* test: mock windows-paths exports for isSecurePath

Signed-off-by: Umaru <caleb.1331@outlook.com>

* test: make cli env tests platform-aware

Signed-off-by: Umaru <caleb.1331@outlook.com>

* test: cover isSecurePath guard in claude detection

Signed-off-by: Umaru <caleb.1331@outlook.com>

* test: align env-utils mocks with shouldUseShell

Signed-off-by: Umaru <caleb.1331@outlook.com>

* test: assert isSecurePath for cmd path

* fix(frontend): handle quoted claude.cmd paths in validation

---------

Signed-off-by: Umaru <caleb.1331@outlook.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>

* 2.7.4 release

* changelog 2.7.4

---------

Signed-off-by: Black Circle Sentinel <mludlow000@icloud.com>
Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Signed-off-by: Umaru <caleb.1331@outlook.com>
Co-authored-by: StillKnotKnown <192589389+StillKnotKnown@users.noreply.github.com>
Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Michael Ludlow <mludlow000@icloud.com>
Co-authored-by: Test User <test@example.com>
Co-authored-by: Umaru <caleb.1331@outlook.com>
2026-01-13 22:46:29 +01:00
Andy 8fb0209d9c docs: update README download links to v2.7.3 (#976)
- Update all stable download links from 2.7.2 to 2.7.3
- Add Flatpak download link (new in 2.7.3)
2026-01-12 21:44:36 +01:00
133 changed files with 6815 additions and 1052 deletions
+2 -140
View File
@@ -522,144 +522,6 @@ jobs:
sha256sum ./* > checksums.sha256
cat checksums.sha256
- name: Scan with VirusTotal
id: virustotal
continue-on-error: true
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
env:
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
run: |
if [ -z "$VT_API_KEY" ]; then
echo "::warning::VIRUSTOTAL_API_KEY not configured, skipping scan"
echo "vt_results=" >> $GITHUB_OUTPUT
exit 0
fi
echo "## VirusTotal Scan Results" > vt_results.md
echo "" >> vt_results.md
for file in release-assets/*.{exe,dmg,AppImage,deb,flatpak}; do
[ -f "$file" ] || continue
filename=$(basename "$file")
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
echo "Scanning $filename (${filesize} bytes)..."
# For files > 32MB, get a special upload URL first
if [ "$filesize" -gt 33554432 ]; then
echo " Large file detected, requesting upload URL..."
upload_url_response=$(curl -s --request GET \
--url "https://www.virustotal.com/api/v3/files/upload_url" \
--header "x-apikey: $VT_API_KEY")
upload_url=$(echo "$upload_url_response" | jq -r '.data // empty')
if [ -z "$upload_url" ]; then
echo "::warning::Failed to get upload URL for large file $filename"
echo "Response: $upload_url_response"
echo "- $filename - ⚠️ Upload failed (large file)" >> vt_results.md
continue
fi
api_url="$upload_url"
else
api_url="https://www.virustotal.com/api/v3/files"
fi
# Upload file to VirusTotal
response=$(curl -s --request POST \
--url "$api_url" \
--header "x-apikey: $VT_API_KEY" \
--form "file=@$file")
# Check if response is valid JSON before parsing
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
echo "::warning::VirusTotal returned invalid JSON for $filename"
echo "Response (first 500 chars): ${response:0:500}"
echo "- $filename - ⚠️ Scan failed (invalid response)" >> vt_results.md
continue
fi
# Check for API error response
error_code=$(echo "$response" | jq -r '.error.code // empty')
if [ -n "$error_code" ]; then
error_msg=$(echo "$response" | jq -r '.error.message // "Unknown error"')
echo "::warning::VirusTotal API error for $filename: $error_code - $error_msg"
echo "- $filename - ⚠️ Scan failed ($error_code)" >> vt_results.md
continue
fi
# Extract analysis ID
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
if [ -z "$analysis_id" ]; then
echo "::warning::Failed to upload $filename to VirusTotal"
echo "Response: $response"
echo "- $filename - ⚠️ Upload failed" >> vt_results.md
continue
fi
echo "Uploaded $filename, analysis ID: $analysis_id"
# Wait for analysis to complete (max 5 minutes per file)
analysis=""
for i in {1..30}; do
sleep 10
analysis=$(curl -s --request GET \
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
--header "x-apikey: $VT_API_KEY")
# Validate JSON response
if ! echo "$analysis" | jq -e . >/dev/null 2>&1; then
echo " Warning: Invalid JSON response on attempt $i, retrying..."
continue
fi
status=$(echo "$analysis" | jq -r '.data.attributes.status // "unknown"')
echo " Status: $status (attempt $i/30)"
if [ "$status" = "completed" ]; then
break
fi
done
# Final validation that we have valid analysis data
if ! echo "$analysis" | jq -e '.data.attributes.stats' >/dev/null 2>&1; then
echo "::warning::Could not get complete analysis for $filename, using local hash"
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis incomplete" >> vt_results.md
continue
fi
# Get file hash for permanent URL
file_hash=$(echo "$analysis" | jq -r '.meta.file_info.sha256 // empty')
if [ -z "$file_hash" ]; then
# Fallback: calculate hash locally
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
fi
# Get detection stats
malicious=$(echo "$analysis" | jq -r '.data.attributes.stats.malicious // 0')
suspicious=$(echo "$analysis" | jq -r '.data.attributes.stats.suspicious // 0')
undetected=$(echo "$analysis" | jq -r '.data.attributes.stats.undetected // 0')
vt_url="https://www.virustotal.com/gui/file/$file_hash"
if [ "$malicious" -gt 0 ] || [ "$suspicious" -gt 0 ]; then
echo "::warning::$filename has $malicious malicious and $suspicious suspicious detections (likely false positives)"
echo "- [$filename]($vt_url) - ⚠️ **$malicious malicious, $suspicious suspicious** detections (review recommended)" >> vt_results.md
else
echo "$filename is clean ($undetected engines, 0 detections)"
echo "- [$filename]($vt_url) - ✅ Clean ($undetected engines, 0 detections)" >> vt_results.md
fi
done
echo "" >> vt_results.md
# Save results for release notes
cat vt_results.md
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
cat vt_results.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Dry run summary
if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }}
run: |
@@ -741,9 +603,9 @@ jobs:
---
${{ steps.virustotal.outputs.vt_results }}
**Full Changelog**: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md
_VirusTotal scan results will be added automatically after release._
files: release-assets/*
draft: false
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
+21
View File
@@ -0,0 +1,21 @@
name: Test Azure Auth
on:
workflow_dispatch:
jobs:
test-auth:
runs-on: windows-latest
permissions:
id-token: write
contents: read
steps:
- name: Azure Login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Success
run: echo "Azure authentication successful!"
+343
View File
@@ -0,0 +1,343 @@
name: VirusTotal Scan
# Runs AFTER release is published to avoid blocking release creation
# VirusTotal scans can take 5+ minutes per file, which delays releases
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to scan (e.g., v2.8.0)'
required: true
type: string
# Prevent TOCTOU race condition when updating release notes
# If two runs target the same tag, queue them instead of running in parallel
concurrency:
group: virustotal-${{ github.event.inputs.tag || github.event.release.tag_name }}
cancel-in-progress: false
jobs:
scan:
name: Scan release assets
runs-on: ubuntu-latest
permissions:
contents: write # Required to update release notes
steps:
- name: Determine release tag
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
fi
- name: Check for API key
id: check-key
env:
VT_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
run: |
if [ -z "$VT_KEY" ]; then
echo "::warning::VIRUSTOTAL_API_KEY not configured, skipping scan"
echo "has_key=false" >> $GITHUB_OUTPUT
else
echo "has_key=true" >> $GITHUB_OUTPUT
fi
- name: Download release assets
if: steps.check-key.outputs.has_key == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.tag.outputs.tag }}"
echo "Downloading assets for release $TAG..."
mkdir -p release-assets
# First verify the release exists
if ! gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "::error::Release $TAG not found"
exit 1
fi
# Download assets, distinguishing between "no matching assets" and real errors
set +e
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern "*.exe" \
--pattern "*.dmg" \
--pattern "*.AppImage" \
--pattern "*.deb" \
--pattern "*.flatpak" \
--dir release-assets 2>&1
exit_code=$?
set -e
if [ $exit_code -ne 0 ]; then
# Check if it's just "no assets matched" vs a real error
asset_count=$(gh release view "$TAG" --repo "${{ github.repository }}" --json assets -q '.assets | length')
if [ "$asset_count" -eq 0 ]; then
echo "Release has no assets yet (this is OK for new releases)"
else
# Check if any scannable assets exist that should have been downloaded
scannable_assets=$(gh release view "$TAG" --repo "${{ github.repository }}" --json assets \
-q '.assets[].name | select(test("\\.(exe|dmg|AppImage|deb|flatpak)$"))' | wc -l)
if [ "$scannable_assets" -gt 0 ]; then
echo "::error::Download failed - $scannable_assets scannable asset(s) exist but download failed"
exit 1
fi
echo "No assets matched the patterns (exe, dmg, AppImage, deb, flatpak)"
fi
fi
echo "Downloaded assets:"
ls -la release-assets/ || echo "No assets found"
- name: Scan with VirusTotal
if: steps.check-key.outputs.has_key == 'true'
id: virustotal
env:
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
run: |
echo "## VirusTotal Scan Results" > vt_results.md
echo "" >> vt_results.md
# Check if there are any files to scan
shopt -s nullglob
files=(release-assets/*.{exe,dmg,AppImage,deb,flatpak})
if [ ${#files[@]} -eq 0 ]; then
echo "No scannable files found in release assets"
echo "- No executable files found in release" >> vt_results.md
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
cat vt_results.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
exit 0
fi
for file in "${files[@]}"; do
[ -f "$file" ] || continue
filename=$(basename "$file")
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
echo "Scanning $filename (${filesize} bytes)..."
# VirusTotal requires special upload URL for files > 32MB
LARGE_FILE_THRESHOLD=33554432 # 32 MB in bytes
if [ "$filesize" -gt "$LARGE_FILE_THRESHOLD" ]; then
echo " Large file detected, requesting upload URL..."
upload_http_response=$(curl -s -w '\n%{http_code}' --request GET \
--url "https://www.virustotal.com/api/v3/files/upload_url" \
--header "x-apikey: $VT_API_KEY")
upload_http_code=$(echo "$upload_http_response" | tail -1)
upload_url_response=$(echo "$upload_http_response" | sed '$d')
if [ "$upload_http_code" != "200" ]; then
echo "::warning::Failed to get upload URL for large file $filename (HTTP $upload_http_code)"
echo "- $filename - ⚠️ Upload failed (large file, HTTP $upload_http_code)" >> vt_results.md
continue
fi
upload_url=$(echo "$upload_url_response" | jq -r '.data // empty')
if [ -z "$upload_url" ]; then
echo "::warning::Failed to get upload URL for large file $filename"
echo "Response: $upload_url_response"
echo "- $filename - ⚠️ Upload failed (large file)" >> vt_results.md
continue
fi
api_url="$upload_url"
else
api_url="https://www.virustotal.com/api/v3/files"
fi
# Upload file to VirusTotal (capture HTTP status code)
http_response=$(curl -s -w '\n%{http_code}' --request POST \
--url "$api_url" \
--header "x-apikey: $VT_API_KEY" \
--form "file=@$file")
http_code=$(echo "$http_response" | tail -1)
response=$(echo "$http_response" | sed '$d')
# Check HTTP status code first
if [ "$http_code" != "200" ]; then
echo "::warning::VirusTotal returned HTTP $http_code for $filename"
if [ "$http_code" = "429" ]; then
echo "- $filename - ⚠️ Scan failed (rate limited)" >> vt_results.md
elif [ "$http_code" = "403" ]; then
echo "- $filename - ⚠️ Scan failed (forbidden - check API key)" >> vt_results.md
else
echo "- $filename - ⚠️ Scan failed (HTTP $http_code)" >> vt_results.md
fi
continue
fi
# Check if response is valid JSON before parsing
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
echo "::warning::VirusTotal returned invalid JSON for $filename"
echo "Response (first 500 chars): ${response:0:500}"
echo "- $filename - ⚠️ Scan failed (invalid response)" >> vt_results.md
continue
fi
# Check for API error response
error_code=$(echo "$response" | jq -r '.error.code // empty')
if [ -n "$error_code" ]; then
error_msg=$(echo "$response" | jq -r '.error.message // "Unknown error"')
echo "::warning::VirusTotal API error for $filename: $error_code - $error_msg"
echo "- $filename - ⚠️ Scan failed ($error_code)" >> vt_results.md
continue
fi
# Extract analysis ID
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
if [ -z "$analysis_id" ]; then
echo "::warning::Failed to upload $filename to VirusTotal"
echo "Response: $response"
echo "- $filename - ⚠️ Upload failed" >> vt_results.md
continue
fi
echo "Uploaded $filename, analysis ID: $analysis_id"
# Wait for analysis to complete (max 5 minutes per file)
analysis=""
for i in {1..30}; do
sleep 10
analysis_http_response=$(curl -s -w '\n%{http_code}' --request GET \
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
--header "x-apikey: $VT_API_KEY")
analysis_http_code=$(echo "$analysis_http_response" | tail -1)
analysis=$(echo "$analysis_http_response" | sed '$d')
# Check HTTP status code
if [ "$analysis_http_code" != "200" ]; then
echo " Warning: HTTP $analysis_http_code on attempt $i, retrying..."
if [ "$analysis_http_code" = "429" ]; then
echo " Rate limited, waiting longer..."
sleep 30
fi
continue
fi
# Validate JSON response
if ! echo "$analysis" | jq -e . >/dev/null 2>&1; then
echo " Warning: Invalid JSON response on attempt $i, retrying..."
continue
fi
status=$(echo "$analysis" | jq -r '.data.attributes.status // "unknown"')
echo " Status: $status (attempt $i/30)"
if [ "$status" = "completed" ]; then
break
fi
done
# Handle analysis timeout - if loop completed without status=completed
if [ "$status" != "completed" ]; then
echo "::warning::Analysis timed out for $filename (status: $status after 5 minutes)"
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis timed out" >> vt_results.md
continue
fi
# Final validation that we have valid analysis data
if ! echo "$analysis" | jq -e '.data.attributes.stats' >/dev/null 2>&1; then
echo "::warning::Could not get complete analysis for $filename, using local hash"
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis incomplete" >> vt_results.md
continue
fi
# Get file hash for permanent URL
file_hash=$(echo "$analysis" | jq -r '.meta.file_info.sha256 // empty')
if [ -z "$file_hash" ]; then
# Fallback: calculate hash locally
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
fi
# Get detection stats
malicious=$(echo "$analysis" | jq -r '.data.attributes.stats.malicious // 0')
suspicious=$(echo "$analysis" | jq -r '.data.attributes.stats.suspicious // 0')
undetected=$(echo "$analysis" | jq -r '.data.attributes.stats.undetected // 0')
vt_url="https://www.virustotal.com/gui/file/$file_hash"
if [ "$malicious" -gt 0 ] || [ "$suspicious" -gt 0 ]; then
echo "::warning::$filename has $malicious malicious and $suspicious suspicious detections (likely false positives)"
echo "- [$filename]($vt_url) - ⚠️ **$malicious malicious, $suspicious suspicious** detections (review recommended)" >> vt_results.md
else
echo "$filename is clean ($undetected engines, 0 detections)"
echo "- [$filename]($vt_url) - ✅ Clean ($undetected engines, 0 detections)" >> vt_results.md
fi
done
echo "" >> vt_results.md
# Save results for next step
cat vt_results.md
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
cat vt_results.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Update release notes with scan results
if: steps.check-key.outputs.has_key == 'true' && steps.virustotal.outputs.vt_results != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.tag.outputs.tag }}"
# Get current release body with error checking
if ! current_body=$(gh release view "$TAG" --repo "${{ github.repository }}" --json body -q '.body'); then
echo "::error::Failed to fetch current release body for $TAG"
exit 1
fi
# Additional safeguard for empty body
if [ -z "$current_body" ]; then
echo "::warning::Release body is empty, this may indicate a problem"
fi
# Check if VirusTotal results already exist in the body
if echo "$current_body" | grep -q "## VirusTotal Scan Results"; then
echo "VirusTotal results already in release notes, skipping update"
exit 0
fi
# Use file-based approach to avoid shell expansion issues
# First, write current body to file
echo "$current_body" > release-body.md
# Remove placeholder text if present (portable sed approach)
sed '/_VirusTotal scan results will be added automatically after release\./d' release-body.md > release-body.tmp && mv release-body.tmp release-body.md
# Append separator and VT results
echo "" >> release-body.md
echo "---" >> release-body.md
echo "" >> release-body.md
cat vt_results.md >> release-body.md
# Update release using --notes-file to avoid shell quoting issues
gh release edit "$TAG" \
--repo "${{ github.repository }}" \
--notes-file release-body.md
echo "✅ Updated release notes with VirusTotal scan results"
- name: Summary
if: always()
run: |
echo "## VirusTotal Scan Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Release:** ${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.check-key.outputs.has_key }}" = "false" ]; then
echo "⚠️ Scan skipped: VIRUSTOTAL_API_KEY not configured" >> $GITHUB_STEP_SUMMARY
elif [ -f vt_results.md ]; then
cat vt_results.md >> $GITHUB_STEP_SUMMARY
else
echo "No scan results available" >> $GITHUB_STEP_SUMMARY
fi
+1
View File
@@ -168,3 +168,4 @@ OPUS_ANALYSIS_AND_IDEAS.md
# Auto Claude generated files
.security-key
/shared_docs
+91
View File
@@ -1,3 +1,94 @@
## 2.7.4 - Terminal & Workflow Enhancements
### ✨ New Features
- Added task worktrees section in terminal with ability to invoke Claude with YOLO mode (--dangerously-skip-permissions)
- Added searchable branch combobox to worktree creation dialog for easier branch selection
- Added Claude Code version rollback feature to switch between installed versions
- Embedded Sentry DSN at build time for better error tracking in packaged apps
### 🛠️ Improvements
- Made worktree isolation prominent in UI to help users understand workspace isolation
- Enhanced terminal recreation logic with retry mechanism for more reliable terminal recovery
- Improved worktree name input UX for better user experience
- Improved Claude CLI detection with installation selector when multiple versions found
- Enhanced terminal drag and drop reordering with collision detection
- Synced worktree config to renderer on terminal restoration for consistency
### 🐛 Bug Fixes
- Fixed Windows claude.cmd validation in GUI to work reliably across different setups
- Fixed profile manager initialization timing issue before auth checks
- Fixed terminal recreation and label reset when user closes Claude
- Fixed duplicate Kanban task creation that occurred on rapid button clicks
- Fixed GitHub PR preloading to prevent loading PRs currently under review
- Fixed UI to display actual base branch name instead of hardcoded "main"
- Fixed Claude CLI detection to properly identify available installations
- Fixed broken pipe errors in backend with Sentry integration
- Fixed app update state persistence for Install button visibility
- Fixed merge logic to include files with content changes even when semantic analysis is empty
- Fixed security profile inheritance in worktrees and shell -c command validation
- Fixed auth auto-switch on 401 errors and improved OAuth-only profile handling
- Fixed "already up to date" case handling in worktree operations
- Resolved circular import issues in GitHub context gatherer and services
---
## What's Changed
- fix: validate Windows claude.cmd reliably in GUI by @Umaru in 1ae3359b
- fix: await profile manager initialization before auth check by @StillKnotKnown in c8374bc1
- feat: add file/screenshot upload to QA feedback interface by @Andy in 88277f84
- feat(terminal): add task worktrees section and remove terminal limit by @Andy in 17118b07
- fix(terminal): enhance terminal recreation logic with retry mechanism by @Andy in df1b8a3f
- fix(terminal): improve worktree name input UX by @Andy in 54e9f228
- feat(ui): make worktree isolation prominent in UI by @Andy in 4dbb7ee4
- feat(terminal): add YOLO mode to invoke Claude with --dangerously-skip-permissions by @Andy in d48e5f68
- fix(ui): prevent duplicate Kanban task creation on rapid button clicks by @Andy in 2d1d3ef1
- feat(sentry): embed Sentry DSN at build time for packaged apps by @Andy in aed28c5f
- fix(github): resolve circular import issues in context_gatherer and services by @Andy in 0307a4a9
- fix(github-prs): prevent preloading of PRs currently under review by @Andy in 1babcc86
- fix(ui): display actual base branch name instead of hardcoded main by @Andy in 5d07d5f1
- ci(release): move VirusTotal scan to separate post-release workflow by @Andy in 553d1e8d
- fix: improve Claude CLI detection and add installation selector by @Andy in e07a0dbd
- fix(backend): add Sentry integration and fix broken pipe errors by @Andy in aa9fbe9d
- fix(app-update): persist downloaded update state for Install button visibility by @Andy in 6f059bb5
- fix(terminal): detect Claude exit and reset label when user closes Claude by @Andy in 14982e66
- fix(merge): include files with content changes even when semantic analysis is empty by @Andy in 4736b6b6
- fix(frontend): sync worktree config to renderer on terminal restoration by @Andy in 68fe0860
- feat(frontend): add searchable branch combobox to worktree creation dialog by @Andy in 2a2dc3b8
- fix(security): inherit security profiles in worktrees and validate shell -c commands by @Andy in 750ea8d1
- feat(frontend): add Claude Code version rollback feature by @Andy in 8d21978f
- fix(ACS-181): enable auto-switch on 401 auth errors & OAuth-only profiles by @Michael Ludlow in e7427321
- fix(terminal): add collision detection for terminal drag and drop reordering by @Andy in 1701160b
- fix(worktree): handle "already up to date" case correctly by @StillKnotKnown in 74ed4320
## Thanks to all contributors
@Umaru, @StillKnotKnown, @Andy, @Michael Ludlow, @AndyMik90
## 2.7.3 - Reliability & Stability Focus
### ✨ New Features
+7 -6
View File
@@ -16,17 +16,18 @@
### Stable Release
<!-- STABLE_VERSION_BADGE -->
[![Stable](https://img.shields.io/badge/stable-2.7.3-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.3)
[![Stable](https://img.shields.io/badge/stable-2.7.4-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.4)
<!-- STABLE_VERSION_BADGE_END -->
<!-- STABLE_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-amd64.deb) |
| **Windows** | [Auto-Claude-2.7.3-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.3-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.3-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.3-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.3-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.3-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-linux-x86_64.flatpak) |
<!-- STABLE_DOWNLOADS_END -->
### Beta Release
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.3"
__version__ = "2.7.4"
__author__ = "Auto Claude Team"
+31
View File
@@ -288,6 +288,28 @@ def main() -> None:
# Set up environment first
setup_environment()
# Initialize Sentry early to capture any startup errors
from core.sentry import capture_exception, init_sentry
init_sentry(component="cli")
try:
_run_cli()
except KeyboardInterrupt:
# Clean exit on Ctrl+C
sys.exit(130)
except Exception as e:
# Capture unexpected errors to Sentry
capture_exception(e)
print(f"\nUnexpected error: {e}")
sys.exit(1)
def _run_cli() -> None:
"""Run the CLI logic (extracted for error handling)."""
# Import here to avoid import errors during startup
from core.sentry import set_context
# Parse arguments
args = parse_args()
@@ -358,6 +380,15 @@ def main() -> None:
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
# Set Sentry context for error tracking
set_context(
"spec",
{
"name": spec_dir.name,
"project": str(project_dir),
},
)
# Handle build management commands
if args.merge_preview:
from cli.workspace_commands import handle_merge_preview_command
+299 -1
View File
@@ -17,6 +17,8 @@ import json
import logging
import os
import platform
import shutil
import subprocess
import threading
import time
from pathlib import Path
@@ -121,6 +123,290 @@ def invalidate_project_cache(project_dir: Path | None = None) -> None:
logger.debug(f"Invalidated project index cache for {project_dir}")
# =============================================================================
# Claude CLI Path Detection
# =============================================================================
# Cross-platform detection of Claude Code CLI binary.
# This mirrors the frontend's cli-tool-manager.ts logic to ensure consistency.
_CLAUDE_CLI_CACHE: dict[str, str | None] = {}
_CLI_CACHE_LOCK = threading.Lock()
def _get_claude_detection_paths() -> dict[str, list[str]]:
"""
Get all candidate paths for Claude CLI detection.
Returns platform-specific paths where Claude CLI might be installed.
IMPORTANT: This function mirrors the frontend's getClaudeDetectionPaths()
in apps/frontend/src/main/cli-tool-manager.ts. Both implementations MUST
be kept in sync to ensure consistent detection behavior across the
Python backend and Electron frontend.
When adding new detection paths, update BOTH:
1. This function (_get_claude_detection_paths in client.py)
2. getClaudeDetectionPaths() in cli-tool-manager.ts
Returns:
Dict with 'homebrew', 'platform', and 'nvm' path lists
"""
home_dir = Path.home()
is_windows = platform.system() == "Windows"
homebrew_paths = [
"/opt/homebrew/bin/claude", # Apple Silicon
"/usr/local/bin/claude", # Intel Mac
]
if is_windows:
platform_paths = [
str(home_dir / "AppData" / "Local" / "Programs" / "claude" / "claude.exe"),
str(home_dir / "AppData" / "Roaming" / "npm" / "claude.cmd"),
str(home_dir / ".local" / "bin" / "claude.exe"),
"C:\\Program Files\\Claude\\claude.exe",
"C:\\Program Files (x86)\\Claude\\claude.exe",
]
else:
platform_paths = [
str(home_dir / ".local" / "bin" / "claude"),
str(home_dir / "bin" / "claude"),
]
nvm_versions_dir = str(home_dir / ".nvm" / "versions" / "node")
return {
"homebrew": homebrew_paths,
"platform": platform_paths,
"nvm_versions_dir": nvm_versions_dir,
}
def _is_secure_path(path_str: str) -> bool:
"""
Validate that a path doesn't contain dangerous characters.
Prevents command injection attacks by rejecting paths with shell metacharacters,
directory traversal patterns, or environment variable expansion.
Args:
path_str: Path to validate
Returns:
True if the path is safe, False otherwise
"""
import re
dangerous_patterns = [
r'[;&|`${}[\]<>!"^]', # Shell metacharacters
r"%[^%]+%", # Windows environment variable expansion
r"\.\./", # Unix directory traversal
r"\.\.\\", # Windows directory traversal
r"[\r\n]", # Newlines (command injection)
]
for pattern in dangerous_patterns:
if re.search(pattern, path_str):
return False
return True
def _validate_claude_cli(cli_path: str) -> tuple[bool, str | None]:
"""
Validate that a Claude CLI path is executable and returns a version.
Includes security validation to prevent command injection attacks.
Args:
cli_path: Path to the Claude CLI executable
Returns:
Tuple of (is_valid, version_string or None)
Note:
Cross-references with frontend's validateClaudeCliAsync() in
apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts
Both should be kept in sync for consistent behavior.
"""
import re
# Security validation: reject paths with shell metacharacters or directory traversal
if not _is_secure_path(cli_path):
logger.warning(f"Rejecting insecure Claude CLI path: {cli_path}")
return False, None
try:
is_windows = platform.system() == "Windows"
# Augment PATH with the CLI directory for proper resolution
env = os.environ.copy()
cli_dir = os.path.dirname(cli_path)
if cli_dir:
env["PATH"] = cli_dir + os.pathsep + env.get("PATH", "")
# For Windows .cmd/.bat files, use cmd.exe with proper quoting
# /d = disable AutoRun registry commands
# /s = strip first and last quotes, preserving inner quotes
# /c = run command then terminate
if is_windows and cli_path.lower().endswith((".cmd", ".bat")):
# Get cmd.exe path from environment or use default
cmd_exe = os.environ.get("ComSpec") or os.path.join(
os.environ.get("SystemRoot", "C:\\Windows"), "System32", "cmd.exe"
)
# Use double-quoted command line for paths with spaces
cmd_line = f'""{cli_path}" --version"'
result = subprocess.run(
[cmd_exe, "/d", "/s", "/c", cmd_line],
capture_output=True,
text=True,
timeout=5,
env=env,
creationflags=subprocess.CREATE_NO_WINDOW,
)
else:
result = subprocess.run(
[cli_path, "--version"],
capture_output=True,
text=True,
timeout=5,
env=env,
creationflags=subprocess.CREATE_NO_WINDOW if is_windows else 0,
)
if result.returncode == 0:
# Extract version from output (e.g., "claude-code version 1.0.0")
output = result.stdout.strip()
match = re.search(r"(\d+\.\d+\.\d+)", output)
version = match.group(1) if match else output.split("\n")[0]
return True, version
return False, None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
logger.debug(f"Claude CLI validation failed for {cli_path}: {e}")
return False, None
def find_claude_cli() -> str | None:
"""
Find the Claude Code CLI binary path.
Uses cross-platform detection with the following priority:
1. CLAUDE_CLI_PATH environment variable (user override)
2. shutil.which() - system PATH lookup
3. Homebrew paths (macOS)
4. NVM paths (Unix - checks Node.js version manager)
5. Platform-specific standard locations
Returns:
Path to Claude CLI if found and valid, None otherwise
"""
# Check cache first
cache_key = "claude_cli"
with _CLI_CACHE_LOCK:
if cache_key in _CLAUDE_CLI_CACHE:
cached = _CLAUDE_CLI_CACHE[cache_key]
logger.debug(f"Using cached Claude CLI path: {cached}")
return cached
is_windows = platform.system() == "Windows"
paths = _get_claude_detection_paths()
# 1. Check environment variable override
env_path = os.environ.get("CLAUDE_CLI_PATH")
if env_path:
if Path(env_path).exists():
valid, version = _validate_claude_cli(env_path)
if valid:
logger.info(f"Using CLAUDE_CLI_PATH: {env_path} (v{version})")
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = env_path
return env_path
logger.warning(f"CLAUDE_CLI_PATH is set but invalid: {env_path}")
# 2. Try shutil.which() - most reliable cross-platform PATH lookup
which_path = shutil.which("claude")
if which_path:
valid, version = _validate_claude_cli(which_path)
if valid:
logger.info(f"Found Claude CLI in PATH: {which_path} (v{version})")
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = which_path
return which_path
# 3. Homebrew paths (macOS)
if platform.system() == "Darwin":
for hb_path in paths["homebrew"]:
if Path(hb_path).exists():
valid, version = _validate_claude_cli(hb_path)
if valid:
logger.info(f"Found Claude CLI (Homebrew): {hb_path} (v{version})")
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = hb_path
return hb_path
# 4. NVM paths (Unix only) - check Node.js version manager installations
if not is_windows:
nvm_dir = Path(paths["nvm_versions_dir"])
if nvm_dir.exists():
try:
# Get all version directories and sort by version (newest first)
version_dirs = []
for entry in nvm_dir.iterdir():
if entry.is_dir() and entry.name.startswith("v"):
# Parse version: v20.0.0 -> (20, 0, 0)
try:
parts = entry.name[1:].split(".")
if len(parts) == 3:
version_dirs.append(
(tuple(int(p) for p in parts), entry.name)
)
except ValueError:
continue
# Sort by version descending (newest first)
version_dirs.sort(reverse=True)
for _, version_name in version_dirs:
nvm_claude = nvm_dir / version_name / "bin" / "claude"
if nvm_claude.exists():
valid, version = _validate_claude_cli(str(nvm_claude))
if valid:
logger.info(
f"Found Claude CLI (NVM): {nvm_claude} (v{version})"
)
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = str(nvm_claude)
return str(nvm_claude)
except OSError as e:
logger.debug(f"Error scanning NVM directory: {e}")
# 5. Platform-specific standard locations
for plat_path in paths["platform"]:
if Path(plat_path).exists():
valid, version = _validate_claude_cli(plat_path)
if valid:
logger.info(f"Found Claude CLI: {plat_path} (v{version})")
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = plat_path
return plat_path
# Not found
logger.warning(
"Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
)
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = None
return None
def clear_claude_cli_cache() -> None:
"""Clear the Claude CLI path cache, forcing re-detection on next call."""
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE.clear()
logger.debug("Claude CLI cache cleared")
from agents.tools_pkg import (
CONTEXT7_TOOLS,
ELECTRON_TOOLS,
@@ -780,8 +1066,16 @@ def create_client(
print(" - CLAUDE.md: disabled by project settings")
print()
# Find Claude CLI path for SDK
# This ensures the SDK can find the Claude Code binary even if it's not in PATH
cli_path = find_claude_cli()
if cli_path:
print(f" - Claude CLI: {cli_path}")
else:
print(" - Claude CLI: using SDK default detection")
# Build options dict, conditionally including output_format
options_kwargs = {
options_kwargs: dict[str, Any] = {
"model": model,
"system_prompt": base_prompt,
"allowed_tools": allowed_tools_list,
@@ -804,6 +1098,10 @@ def create_client(
"enable_file_checkpointing": True,
}
# Add CLI path if found (helps SDK find Claude Code in non-standard locations)
if cli_path:
options_kwargs["cli_path"] = cli_path
# Add structured output format if specified
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
if output_format:
+94
View File
@@ -0,0 +1,94 @@
"""
I/O Utilities for Safe Console Output
=====================================
Safe I/O operations for processes running as subprocesses.
When the backend runs as a subprocess of the Electron app, the parent
process may close the pipe at any time (e.g., user closes the app,
process killed, etc.). This module provides utilities to handle these
cases gracefully.
"""
from __future__ import annotations
import logging
import sys
logger = logging.getLogger(__name__)
# Track if pipe is broken to avoid repeated failed writes
_pipe_broken = False
def safe_print(message: str, flush: bool = True) -> None:
"""
Print to stdout with BrokenPipeError handling.
When running as a subprocess (e.g., from Electron), the parent process
may close the pipe at any time. This function gracefully handles that
case instead of raising an exception.
Args:
message: The message to print
flush: Whether to flush stdout after printing (default True)
"""
global _pipe_broken
# Skip if we already know the pipe is broken
if _pipe_broken:
return
try:
print(message, flush=flush)
except BrokenPipeError:
# Pipe closed by parent process - this is expected during shutdown
_pipe_broken = True
# Quietly close stdout to prevent further errors
try:
sys.stdout.close()
except Exception:
pass
logger.debug("Output pipe closed by parent process")
except ValueError as e:
# Handle writes to closed file (can happen after stdout.close())
if "closed file" in str(e).lower():
_pipe_broken = True
logger.debug("Output stream closed")
else:
# Re-raise unexpected ValueErrors
raise
except OSError as e:
# Handle other pipe-related errors (EPIPE, etc.)
if e.errno == 32: # EPIPE - Broken pipe
_pipe_broken = True
try:
sys.stdout.close()
except Exception:
pass
logger.debug("Output pipe closed (EPIPE)")
else:
# Re-raise unexpected OS errors
raise
def is_pipe_broken() -> bool:
"""Check if the output pipe has been closed."""
return _pipe_broken
def reset_pipe_state() -> None:
"""
Reset pipe broken state.
Useful for testing or when starting a new subprocess context where
stdout has been reopened. Should only be called when stdout is known
to be functional (e.g., in a fresh subprocess with a new stdout).
Warning:
Calling this after stdout has been closed will result in safe_print()
attempting to write to the closed stream. The ValueError will be
caught and the pipe will be marked as broken again.
"""
global _pipe_broken
_pipe_broken = False
+406
View File
@@ -0,0 +1,406 @@
"""
Sentry Error Tracking for Python Backend
=========================================
Initializes Sentry for the Python backend with:
- Privacy-preserving path masking (usernames removed)
- Release tracking matching the Electron frontend
- Environment variable configuration (same as frontend)
Configuration:
- SENTRY_DSN: Required to enable Sentry (same as frontend)
- SENTRY_TRACES_SAMPLE_RATE: Performance monitoring sample rate (0-1, default: 0.1)
- SENTRY_ENVIRONMENT: Override environment (default: auto-detected)
Privacy Note:
- Usernames are masked from all file paths
- Project paths remain visible for debugging (this is expected)
- No user identifiers are collected
"""
from __future__ import annotations
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Track initialization state
_sentry_initialized = False
_sentry_enabled = False
# Production trace sample rate (10%)
PRODUCTION_TRACE_SAMPLE_RATE = 0.1
def _get_version() -> str:
"""
Get the application version.
Tries to read from package.json in the frontend directory,
falling back to a default version.
"""
try:
# Try to find package.json relative to this file
backend_dir = Path(__file__).parent.parent
frontend_dir = backend_dir.parent / "frontend"
package_json = frontend_dir / "package.json"
if package_json.exists():
import json
with open(package_json) as f:
data = json.load(f)
return data.get("version", "0.0.0")
except Exception as e:
logger.debug(f"Version detection failed: {e}")
return "0.0.0"
def _mask_user_paths(text: str) -> str:
"""
Mask user-specific paths for privacy.
Replaces usernames in common OS path patterns:
- macOS: /Users/username/... becomes /Users/***/...
- Windows: C:\\Users\\username\\... becomes C:\\Users\\***\\...
- Linux: /home/username/... becomes /home/***/...
- WSL: /mnt/c/Users/username/... becomes /mnt/c/Users/***/...
Note: Project paths remain visible for debugging purposes.
"""
if not text:
return text
# macOS: /Users/username/...
text = re.sub(r"/Users/[^/]+(?=/|$)", "/Users/***", text)
# Windows: C:\Users\username\...
text = re.sub(
r"[A-Za-z]:\\Users\\[^\\]+(?=\\|$)",
lambda m: f"{m.group(0)[0]}:\\Users\\***",
text,
)
# Linux: /home/username/...
text = re.sub(r"/home/[^/]+(?=/|$)", "/home/***", text)
# WSL: /mnt/c/Users/username/... (accessing Windows filesystem from WSL)
text = re.sub(
r"/mnt/[a-z]/Users/[^/]+(?=/|$)",
lambda m: f"{m.group(0)[:6]}/Users/***",
text,
)
return text
def _mask_object_paths(obj: Any, _depth: int = 0) -> Any:
"""
Recursively mask paths in an object.
Args:
obj: The object to mask paths in
_depth: Current recursion depth (internal use)
Returns:
Object with paths masked
"""
# Prevent stack overflow on deeply nested or circular structures
if _depth > 50:
return obj
if obj is None:
return obj
if isinstance(obj, str):
return _mask_user_paths(obj)
if isinstance(obj, list):
return [_mask_object_paths(item, _depth + 1) for item in obj]
if isinstance(obj, dict):
return {
key: _mask_object_paths(value, _depth + 1) for key, value in obj.items()
}
return obj
def _before_send(event: dict, hint: dict) -> dict | None:
"""
Process event before sending to Sentry.
Applies privacy masking to all paths in the event.
"""
if not _sentry_enabled:
return None
# Mask paths in exception stack traces
if "exception" in event and "values" in event["exception"]:
for exception in event["exception"]["values"]:
if "stacktrace" in exception and "frames" in exception["stacktrace"]:
for frame in exception["stacktrace"]["frames"]:
if "filename" in frame:
frame["filename"] = _mask_user_paths(frame["filename"])
if "abs_path" in frame:
frame["abs_path"] = _mask_user_paths(frame["abs_path"])
if "value" in exception:
exception["value"] = _mask_user_paths(exception["value"])
# Mask paths in breadcrumbs
if "breadcrumbs" in event:
for breadcrumb in event.get("breadcrumbs", {}).get("values", []):
if "message" in breadcrumb:
breadcrumb["message"] = _mask_user_paths(breadcrumb["message"])
if "data" in breadcrumb:
breadcrumb["data"] = _mask_object_paths(breadcrumb["data"])
# Mask paths in message
if "message" in event:
event["message"] = _mask_user_paths(event["message"])
# Mask paths in tags
if "tags" in event:
event["tags"] = _mask_object_paths(event["tags"])
# Mask paths in contexts
if "contexts" in event:
event["contexts"] = _mask_object_paths(event["contexts"])
# Mask paths in extra data
if "extra" in event:
event["extra"] = _mask_object_paths(event["extra"])
# Clear user info for privacy
if "user" in event:
event["user"] = {}
return event
def init_sentry(
component: str = "backend",
force_enable: bool = False,
) -> bool:
"""
Initialize Sentry for the Python backend.
Args:
component: Component name for tagging (e.g., "backend", "github-runner")
force_enable: Force enable even without packaged app detection
Returns:
True if Sentry was initialized, False otherwise
"""
global _sentry_initialized, _sentry_enabled
if _sentry_initialized:
return _sentry_enabled
_sentry_initialized = True
# Get DSN from environment variable
dsn = os.environ.get("SENTRY_DSN", "")
if not dsn:
logger.debug("[Sentry] No SENTRY_DSN configured - error reporting disabled")
return False
# Check if we should enable Sentry
# Enable if:
# - Running from packaged app (detected by __compiled__ or frozen)
# - SENTRY_DEV=true is set
# - force_enable is True
is_packaged = getattr(sys, "frozen", False) or hasattr(sys, "__compiled__")
sentry_dev = os.environ.get("SENTRY_DEV", "").lower() in ("true", "1", "yes")
should_enable = is_packaged or sentry_dev or force_enable
if not should_enable:
logger.debug(
"[Sentry] Development mode - error reporting disabled (set SENTRY_DEV=true to enable)"
)
return False
try:
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
except ImportError:
logger.warning("[Sentry] sentry-sdk not installed - error reporting disabled")
return False
# Get configuration from environment variables
version = _get_version()
environment = os.environ.get(
"SENTRY_ENVIRONMENT", "production" if is_packaged else "development"
)
# Get sample rates
traces_sample_rate = PRODUCTION_TRACE_SAMPLE_RATE
try:
env_rate = os.environ.get("SENTRY_TRACES_SAMPLE_RATE")
if env_rate:
parsed = float(env_rate)
if 0 <= parsed <= 1:
traces_sample_rate = parsed
except (ValueError, TypeError):
pass
# Configure logging integration to capture errors and warnings
logging_integration = LoggingIntegration(
level=logging.INFO, # Capture INFO and above as breadcrumbs
event_level=logging.ERROR, # Send ERROR and above as events
)
# Initialize Sentry
sentry_sdk.init(
dsn=dsn,
environment=environment,
release=f"auto-claude@{version}",
traces_sample_rate=traces_sample_rate,
before_send=_before_send,
integrations=[logging_integration],
# Don't send PII
send_default_pii=False,
)
# Set component tag
sentry_sdk.set_tag("component", component)
_sentry_enabled = True
logger.info(
f"[Sentry] Backend initialized (component: {component}, release: auto-claude@{version}, traces: {traces_sample_rate})"
)
return True
def capture_exception(error: Exception, **kwargs) -> None:
"""
Capture an exception and send to Sentry.
Safe to call even if Sentry is not initialized.
Args:
error: The exception to capture
**kwargs: Additional context to attach to the event
"""
if not _sentry_enabled:
logger.error(f"[Sentry] Not enabled, exception not captured: {error}")
return
try:
import sentry_sdk
with sentry_sdk.push_scope() as scope:
for key, value in kwargs.items():
# Apply defensive path masking for extra data
masked_value = (
_mask_object_paths(value)
if isinstance(value, (str, dict, list))
else value
)
scope.set_extra(key, masked_value)
sentry_sdk.capture_exception(error)
except ImportError:
logger.error(f"[Sentry] SDK not installed, exception not captured: {error}")
except Exception as e:
logger.error(f"[Sentry] Failed to capture exception: {e}")
def capture_message(message: str, level: str = "info", **kwargs) -> None:
"""
Capture a message and send to Sentry.
Safe to call even if Sentry is not initialized.
Args:
message: The message to capture
level: Log level (debug, info, warning, error, fatal)
**kwargs: Additional context to attach to the event
"""
if not _sentry_enabled:
return
try:
import sentry_sdk
with sentry_sdk.push_scope() as scope:
for key, value in kwargs.items():
# Apply defensive path masking for extra data (same as capture_exception)
masked_value = (
_mask_object_paths(value)
if isinstance(value, (str, dict, list))
else value
)
scope.set_extra(key, masked_value)
sentry_sdk.capture_message(message, level=level)
except ImportError:
logger.debug("[Sentry] SDK not installed")
except Exception as e:
logger.error(f"[Sentry] Failed to capture message: {e}")
def set_context(name: str, data: dict) -> None:
"""
Set context data for subsequent events.
Safe to call even if Sentry is not initialized.
Args:
name: Context name (e.g., "pr_review", "spec")
data: Context data dictionary
"""
if not _sentry_enabled:
return
try:
import sentry_sdk
# Apply path masking to context data before sending to Sentry
masked_data = _mask_object_paths(data)
sentry_sdk.set_context(name, masked_data)
except ImportError:
logger.debug("[Sentry] SDK not installed")
except Exception as e:
logger.debug(f"Failed to set context '{name}': {e}")
def set_tag(key: str, value: str) -> None:
"""
Set a tag for subsequent events.
Safe to call even if Sentry is not initialized.
Args:
key: Tag key
value: Tag value
"""
if not _sentry_enabled:
return
try:
import sentry_sdk
# Apply path masking to tag value
masked_value = _mask_user_paths(value) if isinstance(value, str) else value
sentry_sdk.set_tag(key, masked_value)
except ImportError:
logger.debug("[Sentry] SDK not installed")
except Exception as e:
logger.debug(f"Failed to set tag '{key}': {e}")
def is_enabled() -> bool:
"""Check if Sentry is enabled."""
return _sentry_enabled
def is_initialized() -> bool:
"""Check if Sentry initialization has been attempted."""
return _sentry_initialized
+20 -11
View File
@@ -26,6 +26,7 @@ from pathlib import Path
from agents.tools_pkg import get_agent_config, get_default_thinking_level
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from core.auth import get_sdk_env_vars, require_auth_token
from core.client import find_claude_cli
from phase_config import get_thinking_budget
@@ -84,14 +85,22 @@ def create_simple_client(
thinking_level = get_default_thinking_level(agent_type)
max_thinking_tokens = get_thinking_budget(thinking_level)
return ClaudeSDKClient(
options=ClaudeAgentOptions(
model=model,
system_prompt=system_prompt,
allowed_tools=allowed_tools,
max_turns=max_turns,
cwd=str(cwd.resolve()) if cwd else None,
env=sdk_env,
max_thinking_tokens=max_thinking_tokens,
)
)
# Find Claude CLI path (handles non-standard installations)
cli_path = find_claude_cli()
# Build options dict
options_kwargs = {
"model": model,
"system_prompt": system_prompt,
"allowed_tools": allowed_tools,
"max_turns": max_turns,
"cwd": str(cwd.resolve()) if cwd else None,
"env": sdk_env,
"max_thinking_tokens": max_thinking_tokens,
}
# Add CLI path if found
if cli_path:
options_kwargs["cli_path"] = cli_path
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
+17
View File
@@ -296,6 +296,23 @@ def setup_workspace(
f"Security config copied: {', '.join(security_files_copied)}", "success"
)
# Mark the security profile as inherited from parent project
# This prevents hash-based re-analysis which would produce a broken profile
# (worktrees lack node_modules and other build artifacts needed for detection)
if PROFILE_FILENAME in security_files_copied:
profile_path = worktree_info.path / PROFILE_FILENAME
try:
with open(profile_path) as f:
profile_data = json.load(f)
profile_data["inherited_from"] = str(project_dir.resolve())
with open(profile_path, "w") as f:
json.dump(profile_data, f, indent=2)
debug(
MODULE, f"Marked security profile as inherited from {project_dir}"
)
except (OSError, json.JSONDecodeError) as e:
debug_warning(MODULE, f"Failed to mark profile as inherited: {e}")
# Ensure .auto-claude/ is in the worktree's .gitignore
# This is critical because the worktree inherits .gitignore from the base branch,
# which may not have .auto-claude/ if that change wasn't committed/pushed.
+23 -1
View File
@@ -647,7 +647,29 @@ class WorktreeManager:
result = self._run_git(merge_args)
if result.returncode != 0:
print("Merge conflict! Aborting merge...")
# Check if it's "already up to date" - not an error
output = (result.stdout + result.stderr).lower()
if "already up to date" in output or "already up-to-date" in output:
print(f"Branch {info.branch} is already up to date.")
if no_commit:
print("No changes to stage.")
if delete_after:
self.remove_worktree(spec_name, delete_branch=True)
return True
# Check for actual conflicts
if "conflict" in output:
print("Merge conflict! Aborting merge...")
self._run_git(["merge", "--abort"])
return False
# Other error - show details
stderr_msg = (
result.stderr[:200]
if result.stderr
else result.stdout[:200]
if result.stdout
else "<no output>"
)
print(f"Merge failed: {stderr_msg}")
self._run_git(["merge", "--abort"])
return False
@@ -102,7 +102,7 @@ class EvolutionQueries:
modifications = []
for file_path, evolution in evolutions.items():
snapshot = evolution.get_task_snapshot(task_id)
if snapshot and snapshot.semantic_changes:
if snapshot and snapshot.has_modifications:
modifications.append((file_path, snapshot))
return modifications
@@ -125,7 +125,7 @@ class EvolutionQueries:
for file_path, evolution in evolutions.items():
for snapshot in evolution.task_snapshots:
if snapshot.task_id in task_ids and snapshot.semantic_changes:
if snapshot.task_id in task_ids and snapshot.has_modifications:
if file_path not in file_tasks:
file_tasks[file_path] = []
file_tasks[file_path].append(snapshot.task_id)
+12
View File
@@ -75,6 +75,18 @@ class MergePipeline:
# If only one task modified the file, no conflict possible
if len(task_snapshots) == 1:
snapshot = task_snapshots[0]
# Check if file has modifications but semantic analysis returned empty
# This happens for: function body changes, unsupported file types (Rust, Go, etc.)
# In this case, signal that the caller should use the worktree version directly
if snapshot.has_modifications and not snapshot.semantic_changes:
return MergeResult(
decision=MergeDecision.DIRECT_COPY,
file_path=file_path,
merged_content=None, # Caller must read from worktree
explanation=f"File modified by {snapshot.task_id} but no semantic changes detected - use worktree version",
)
merged = apply_single_task_changes(baseline_content, snapshot, file_path)
return MergeResult(
decision=MergeDecision.AUTO_MERGED,
+97 -2
View File
@@ -203,6 +203,58 @@ class MergeOrchestrator:
)
return self._merge_pipeline
def _read_worktree_file_for_direct_copy(
self,
file_path: str,
worktree_path: Path | None,
) -> tuple[str | None, bool]:
"""
Read file content from worktree for DIRECT_COPY merge.
Args:
file_path: Relative path to the file
worktree_path: Path to the worktree directory
Returns:
Tuple of (content, success). If success is False, content is None
and the caller should mark the merge as FAILED.
"""
if not worktree_path:
logger.warning(
f"DIRECT_COPY: No worktree path provided for file: {file_path}"
)
debug_warning(
MODULE,
"DIRECT_COPY: No worktree path provided",
file=file_path,
)
return None, False
worktree_file = worktree_path / file_path
if not worktree_file.exists():
logger.warning(f"DIRECT_COPY: Worktree file not found: {worktree_file}")
debug_warning(
MODULE,
"DIRECT_COPY: Worktree file not found",
file=str(worktree_file),
)
return None, False
try:
content = worktree_file.read_text(encoding="utf-8")
debug_detailed(
MODULE,
f"Read file from worktree for direct copy: {file_path}",
)
return content, True
except UnicodeDecodeError:
content = worktree_file.read_text(encoding="utf-8", errors="replace")
debug_detailed(
MODULE,
f"Read file from worktree with encoding fallback: {file_path}",
)
return content, True
def merge_task(
self,
task_id: str,
@@ -275,6 +327,20 @@ class MergeOrchestrator:
task_snapshots=[snapshot],
target_branch=target_branch,
)
# Handle DIRECT_COPY: read file directly from worktree
# This happens when file has modifications but semantic analysis
# couldn't parse the changes (body modifications, unsupported languages)
if result.decision == MergeDecision.DIRECT_COPY:
content, success = self._read_worktree_file_for_direct_copy(
file_path, worktree_path
)
if success:
result.merged_content = content
else:
result.decision = MergeDecision.FAILED
result.error = "Worktree file not found for DIRECT_COPY"
report.file_results[file_path] = result
self._update_stats(report.stats, result)
debug_verbose(
@@ -374,12 +440,41 @@ class MergeOrchestrator:
task_snapshots=snapshots,
target_branch=target_branch,
)
# Handle DIRECT_COPY: read file directly from worktree
# For multi-task merges, use the first task's worktree that modified this file
if result.decision == MergeDecision.DIRECT_COPY:
# Find the worktree path from the first task that modified this file
worktree_path = None
for tid in modifying_tasks:
for req in requests:
if req.task_id == tid and req.worktree_path:
worktree_path = req.worktree_path
break
if worktree_path:
break
content, success = self._read_worktree_file_for_direct_copy(
file_path, worktree_path
)
if success:
result.merged_content = content
else:
result.decision = MergeDecision.FAILED
result.error = "Worktree file not found for DIRECT_COPY"
report.file_results[file_path] = result
self._update_stats(report.stats, result)
report.success = report.stats.files_failed == 0
except Exception as e:
debug_error(
MODULE,
"Merge failed for tasks",
task_ids=[r.task_id for r in requests],
error=str(e),
)
logger.exception("Merge failed")
report.success = False
report.error = str(e)
@@ -589,7 +684,7 @@ class MergeOrchestrator:
written = []
for file_path, result in report.file_results.items():
if result.merged_content:
if result.merged_content is not None:
out_path = output_dir / file_path
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(result.merged_content, encoding="utf-8")
@@ -640,7 +735,7 @@ class MergeOrchestrator:
)
stats.conflicts_auto_resolved += len(result.conflicts_resolved)
if result.decision == MergeDecision.AUTO_MERGED:
if result.decision in (MergeDecision.AUTO_MERGED, MergeDecision.DIRECT_COPY):
stats.files_auto_merged += 1
elif result.decision == MergeDecision.AI_MERGED:
stats.files_ai_merged += 1
+34 -1
View File
@@ -133,6 +133,7 @@ class MergeDecision(Enum):
AI_MERGED = "ai_merged" # AI resolved the conflict
NEEDS_HUMAN_REVIEW = "needs_human_review" # Flagged for human
FAILED = "failed" # Could not merge
DIRECT_COPY = "direct_copy" # Use worktree version directly (no semantic merge)
@dataclass
@@ -414,6 +415,34 @@ class TaskSnapshot:
raw_diff=data.get("raw_diff"),
)
@property
def has_modifications(self) -> bool:
"""
Check if this snapshot represents actual file modifications.
Returns True if the file was modified, using content hash comparison
as the source of truth. This handles cases where the semantic analyzer
couldn't detect changes (e.g., function body modifications, unsupported
file types like Rust) but the file was actually changed.
Also returns True for newly created files (where content_hash_before
is empty but content_hash_after is set).
"""
# If we have semantic changes, the file was definitely modified
if self.semantic_changes:
return True
# Handle new files: if before is empty but after has content, it's a new file
if not self.content_hash_before and self.content_hash_after:
return True
# Fall back to content hash comparison for files where semantic
# analysis returned empty (body modifications, unsupported languages)
if self.content_hash_before and self.content_hash_after:
return self.content_hash_before != self.content_hash_after
return False
@dataclass
class FileEvolution:
@@ -534,7 +563,11 @@ class MergeResult:
@property
def success(self) -> bool:
"""Check if merge was successful."""
return self.decision in {MergeDecision.AUTO_MERGED, MergeDecision.AI_MERGED}
return self.decision in {
MergeDecision.AUTO_MERGED,
MergeDecision.AI_MERGED,
MergeDecision.DIRECT_COPY,
}
@property
def needs_human_review(self) -> bool:
+35 -2
View File
@@ -188,10 +188,38 @@ class ProjectAnalyzer:
return hasher.hexdigest()
def should_reanalyze(self, profile: SecurityProfile) -> bool:
"""Check if project has changed since last analysis."""
"""Check if project has changed since last analysis.
Never re-analyzes inherited profiles (from worktrees) since they
came from a validated parent project with full context (e.g., node_modules).
"""
# Never re-analyze inherited profiles - they came from a validated parent
# But validate that inherited_from points to a legitimate parent
if profile.inherited_from:
parent = Path(profile.inherited_from)
# Validate the inherited_from path:
# 1. Must exist and be a directory
# 2. Current project must be a descendant of the parent
# 3. Parent must contain a valid security profile
if (
parent.exists()
and parent.is_dir()
and self._is_descendant_of(self.project_dir, parent)
and (parent / self.PROFILE_FILENAME).exists()
):
return False
# If validation fails, treat as non-inherited and check hash
current_hash = self.compute_project_hash()
return current_hash != profile.project_hash
def _is_descendant_of(self, child: Path, parent: Path) -> bool:
"""Check if child path is a descendant of parent path."""
try:
child.resolve().relative_to(parent.resolve())
return True
except ValueError:
return False
def analyze(self, force: bool = False) -> SecurityProfile:
"""
Perform full project analysis.
@@ -205,7 +233,12 @@ class ProjectAnalyzer:
# Check for existing profile
existing = self.load_profile()
if existing and not force and not self.should_reanalyze(existing):
print(f"Using cached security profile (hash: {existing.project_hash[:8]})")
if existing.inherited_from:
print("Using inherited security profile from parent project")
else:
print(
f"Using cached security profile (hash: {existing.project_hash[:8]})"
)
return existing
print("Analyzing project structure for security profile...")
@@ -158,6 +158,10 @@ VALIDATED_COMMANDS: dict[str, str] = {
"pkill": "validate_pkill",
"kill": "validate_kill",
"killall": "validate_killall",
# Shell interpreters - validate commands inside -c
"bash": "validate_shell_c",
"sh": "validate_shell_c",
"zsh": "validate_shell_c",
}
+9 -1
View File
@@ -52,6 +52,9 @@ class SecurityProfile:
project_dir: str = ""
created_at: str = ""
project_hash: str = ""
inherited_from: str = (
"" # Source project path if inherited from parent (e.g., worktree)
)
def get_all_allowed_commands(self) -> set[str]:
"""Get the complete set of allowed commands."""
@@ -64,7 +67,7 @@ class SecurityProfile:
def to_dict(self) -> dict:
"""Convert to JSON-serializable dict."""
return {
result = {
"base_commands": sorted(self.base_commands),
"stack_commands": sorted(self.stack_commands),
"script_commands": sorted(self.script_commands),
@@ -75,6 +78,10 @@ class SecurityProfile:
"created_at": self.created_at,
"project_hash": self.project_hash,
}
# Only include inherited_from if set (to keep backward compatibility)
if self.inherited_from:
result["inherited_from"] = self.inherited_from
return result
@classmethod
def from_dict(cls, data: dict) -> "SecurityProfile":
@@ -87,6 +94,7 @@ class SecurityProfile:
project_dir=data.get("project_dir", ""),
created_at=data.get("created_at", ""),
project_hash=data.get("project_hash", ""),
inherited_from=data.get("inherited_from", ""),
)
if "detected_stack" in data:
+3
View File
@@ -19,3 +19,6 @@ google-generativeai>=0.8.0
# Pydantic for structured output schemas
pydantic>=2.0.0
# Error tracking (optional - requires SENTRY_DSN environment variable)
sentry-sdk>=2.0.0
+54 -50
View File
@@ -25,7 +25,11 @@ from typing import TYPE_CHECKING
try:
from .gh_client import GHClient, PRTooLargeError
from .services.io_utils import safe_print
except (ImportError, ValueError, SystemError):
# Import from core.io_utils directly to avoid circular import with services package
# (services/__init__.py imports pr_review_engine which imports context_gatherer)
from core.io_utils import safe_print
from gh_client import GHClient, PRTooLargeError
# Validation patterns for git refs and paths (defense-in-depth)
@@ -232,11 +236,11 @@ class PRContextGatherer:
Returns:
PRContext with all necessary information for review
"""
print(f"[Context] Gathering context for PR #{self.pr_number}...", flush=True)
safe_print(f"[Context] Gathering context for PR #{self.pr_number}...")
# Fetch basic PR metadata
pr_data = await self._fetch_pr_metadata()
print(
safe_print(
f"[Context] PR metadata: {pr_data['title']} by {pr_data['author']['login']}",
flush=True,
)
@@ -248,7 +252,7 @@ class PRContextGatherer:
if head_sha and base_sha:
refs_available = await self._ensure_pr_refs_available(head_sha, base_sha)
if not refs_available:
print(
safe_print(
"[Context] Warning: Could not fetch PR refs locally. "
"Will use GitHub API patches as fallback.",
flush=True,
@@ -256,27 +260,27 @@ class PRContextGatherer:
# Fetch changed files with content
changed_files = await self._fetch_changed_files(pr_data)
print(f"[Context] Fetched {len(changed_files)} changed files", flush=True)
safe_print(f"[Context] Fetched {len(changed_files)} changed files")
# Fetch full diff
diff = await self._fetch_pr_diff()
print(f"[Context] Fetched diff: {len(diff)} chars", flush=True)
safe_print(f"[Context] Fetched diff: {len(diff)} chars")
# Detect repo structure
repo_structure = self._detect_repo_structure()
print("[Context] Detected repo structure", flush=True)
safe_print("[Context] Detected repo structure")
# Find related files
related_files = self._find_related_files(changed_files)
print(f"[Context] Found {len(related_files)} related files", flush=True)
safe_print(f"[Context] Found {len(related_files)} related files")
# Fetch commits
commits = await self._fetch_commits()
print(f"[Context] Fetched {len(commits)} commits", flush=True)
safe_print(f"[Context] Fetched {len(commits)} commits")
# Fetch AI bot comments for triage
ai_bot_comments = await self._fetch_ai_bot_comments()
print(f"[Context] Fetched {len(ai_bot_comments)} AI bot comments", flush=True)
safe_print(f"[Context] Fetched {len(ai_bot_comments)} AI bot comments")
# Check if diff was truncated (empty diff but files were changed)
diff_truncated = len(diff) == 0 and len(changed_files) > 0
@@ -287,7 +291,7 @@ class PRContextGatherer:
has_merge_conflicts = mergeable == "CONFLICTING"
if has_merge_conflicts:
print(
safe_print(
f"[Context] ⚠️ PR has merge conflicts (mergeStateStatus: {merge_state_status})",
flush=True,
)
@@ -356,12 +360,12 @@ class PRContextGatherer:
"""
# Validate SHAs before using in git commands
if not _validate_git_ref(head_sha):
print(
safe_print(
f"[Context] Invalid head SHA rejected: {head_sha[:50]}...", flush=True
)
return False
if not _validate_git_ref(base_sha):
print(
safe_print(
f"[Context] Invalid base SHA rejected: {base_sha[:50]}...", flush=True
)
return False
@@ -381,14 +385,14 @@ class PRContextGatherer:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
if proc.returncode == 0:
print(
safe_print(
f"[Context] Fetched PR refs: base={base_sha[:8]} → head={head_sha[:8]}",
flush=True,
)
return True
else:
# If direct SHA fetch fails, try fetching the PR ref
print("[Context] Direct SHA fetch failed, trying PR ref...", flush=True)
safe_print("[Context] Direct SHA fetch failed, trying PR ref...")
proc2 = await asyncio.create_subprocess_exec(
"git",
"fetch",
@@ -400,21 +404,21 @@ class PRContextGatherer:
)
await asyncio.wait_for(proc2.communicate(), timeout=30.0)
if proc2.returncode == 0:
print(
safe_print(
f"[Context] Fetched PR ref: refs/pr/{self.pr_number}",
flush=True,
)
return True
print(
safe_print(
f"[Context] Failed to fetch PR refs: {stderr.decode('utf-8')}",
flush=True,
)
return False
except asyncio.TimeoutError:
print("[Context] Timeout fetching PR refs", flush=True)
safe_print("[Context] Timeout fetching PR refs")
return False
except Exception as e:
print(f"[Context] Error fetching PR refs: {e}", flush=True)
safe_print(f"[Context] Error fetching PR refs: {e}")
return False
async def _fetch_changed_files(self, pr_data: dict) -> list[ChangedFile]:
@@ -435,7 +439,7 @@ class PRContextGatherer:
additions = file_info.get("additions", 0)
deletions = file_info.get("deletions", 0)
print(f"[Context] Processing {path} ({status})...", flush=True)
safe_print(f"[Context] Processing {path} ({status})...")
# Use commit SHAs if available (works for fork PRs), fallback to branch names
head_ref = pr_data.get("headRefOid") or pr_data["headRefName"]
@@ -491,10 +495,10 @@ class PRContextGatherer:
"""
# Validate inputs to prevent command injection
if not _validate_file_path(path):
print(f"[Context] Invalid file path rejected: {path[:50]}...", flush=True)
safe_print(f"[Context] Invalid file path rejected: {path[:50]}...")
return ""
if not _validate_git_ref(ref):
print(f"[Context] Invalid git ref rejected: {ref[:50]}...", flush=True)
safe_print(f"[Context] Invalid git ref rejected: {ref[:50]}...")
return ""
try:
@@ -515,10 +519,10 @@ class PRContextGatherer:
return stdout.decode("utf-8")
except asyncio.TimeoutError:
print(f"[Context] Timeout reading {path} from {ref}", flush=True)
safe_print(f"[Context] Timeout reading {path} from {ref}")
return ""
except Exception as e:
print(f"[Context] Error reading {path} from {ref}: {e}", flush=True)
safe_print(f"[Context] Error reading {path} from {ref}: {e}")
return ""
async def _get_file_patch(self, path: str, base_ref: str, head_ref: str) -> str:
@@ -535,15 +539,15 @@ class PRContextGatherer:
"""
# Validate inputs to prevent command injection
if not _validate_file_path(path):
print(f"[Context] Invalid file path rejected: {path[:50]}...", flush=True)
safe_print(f"[Context] Invalid file path rejected: {path[:50]}...")
return ""
if not _validate_git_ref(base_ref):
print(
safe_print(
f"[Context] Invalid base ref rejected: {base_ref[:50]}...", flush=True
)
return ""
if not _validate_git_ref(head_ref):
print(
safe_print(
f"[Context] Invalid head ref rejected: {head_ref[:50]}...", flush=True
)
return ""
@@ -563,7 +567,7 @@ class PRContextGatherer:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10.0)
if proc.returncode != 0:
print(
safe_print(
f"[Context] Failed to get patch for {path}: {stderr.decode('utf-8')}",
flush=True,
)
@@ -571,10 +575,10 @@ class PRContextGatherer:
return stdout.decode("utf-8")
except asyncio.TimeoutError:
print(f"[Context] Timeout getting patch for {path}", flush=True)
safe_print(f"[Context] Timeout getting patch for {path}")
return ""
except Exception as e:
print(f"[Context] Error getting patch for {path}: {e}", flush=True)
safe_print(f"[Context] Error getting patch for {path}: {e}")
return ""
async def _fetch_pr_diff(self) -> str:
@@ -587,8 +591,8 @@ class PRContextGatherer:
try:
return await self.gh_client.pr_diff(self.pr_number)
except PRTooLargeError as e:
print(f"[Context] Warning: {str(e)}", flush=True)
print(
safe_print(f"[Context] Warning: {str(e)}")
safe_print(
"[Context] Skipping full diff - will use individual file patches",
flush=True,
)
@@ -630,7 +634,7 @@ class PRContextGatherer:
ai_comments.append(ai_comment)
except Exception as e:
print(f"[Context] Error fetching AI bot comments: {e}", flush=True)
safe_print(f"[Context] Error fetching AI bot comments: {e}")
return ai_comments
@@ -698,7 +702,7 @@ class PRContextGatherer:
return json.loads(result.stdout)
return []
except Exception as e:
print(f"[Context] Error fetching review comments: {e}", flush=True)
safe_print(f"[Context] Error fetching review comments: {e}")
return []
async def _fetch_pr_issue_comments(self) -> list[dict]:
@@ -717,7 +721,7 @@ class PRContextGatherer:
return json.loads(result.stdout)
return []
except Exception as e:
print(f"[Context] Error fetching issue comments: {e}", flush=True)
safe_print(f"[Context] Error fetching issue comments: {e}")
return []
def _detect_repo_structure(self) -> str:
@@ -1015,7 +1019,7 @@ class FollowupContextGatherer:
previous_sha = self.previous_review.reviewed_commit_sha
if not previous_sha:
print(
safe_print(
"[Followup] No reviewed_commit_sha in previous review, cannot gather incremental context",
flush=True,
)
@@ -1026,7 +1030,7 @@ class FollowupContextGatherer:
current_commit_sha="",
)
print(
safe_print(
f"[Followup] Gathering context since commit {previous_sha[:8]}...",
flush=True,
)
@@ -1035,7 +1039,7 @@ class FollowupContextGatherer:
current_sha = await self.gh_client.get_pr_head_sha(self.pr_number)
if not current_sha:
print("[Followup] Could not fetch current HEAD SHA", flush=True)
safe_print("[Followup] Could not fetch current HEAD SHA")
return FollowupReviewContext(
pr_number=self.pr_number,
previous_review=self.previous_review,
@@ -1044,7 +1048,7 @@ class FollowupContextGatherer:
)
if previous_sha == current_sha:
print("[Followup] No new commits since last review", flush=True)
safe_print("[Followup] No new commits since last review")
return FollowupReviewContext(
pr_number=self.pr_number,
previous_review=self.previous_review,
@@ -1052,7 +1056,7 @@ class FollowupContextGatherer:
current_commit_sha=current_sha,
)
print(
safe_print(
f"[Followup] Comparing {previous_sha[:8]}...{current_sha[:8]}", flush=True
)
@@ -1065,29 +1069,29 @@ class FollowupContextGatherer:
pr_files, new_commits = await self.gh_client.get_pr_files_changed_since(
self.pr_number, previous_sha, reviewed_file_blobs=reviewed_file_blobs
)
print(
safe_print(
f"[Followup] PR has {len(pr_files)} files, "
f"{len(new_commits)} commits since last review"
+ (" (blob comparison used)" if reviewed_file_blobs else ""),
flush=True,
)
except Exception as e:
print(f"[Followup] Error getting PR files/commits: {e}", flush=True)
safe_print(f"[Followup] Error getting PR files/commits: {e}")
# Fallback to compare_commits if PR endpoints fail
print("[Followup] Falling back to commit comparison...", flush=True)
safe_print("[Followup] Falling back to commit comparison...")
try:
comparison = await self.gh_client.compare_commits(
previous_sha, current_sha
)
new_commits = comparison.get("commits", [])
pr_files = comparison.get("files", [])
print(
safe_print(
f"[Followup] Fallback: Found {len(new_commits)} commits, "
f"{len(pr_files)} files (may include merge-introduced changes)",
flush=True,
)
except Exception as e2:
print(f"[Followup] Fallback also failed: {e2}", flush=True)
safe_print(f"[Followup] Fallback also failed: {e2}")
return FollowupReviewContext(
pr_number=self.pr_number,
previous_review=self.previous_review,
@@ -1099,7 +1103,7 @@ class FollowupContextGatherer:
# Use PR files as the canonical list (excludes files from merged branches)
commits = new_commits
files = pr_files
print(
safe_print(
f"[Followup] Found {len(commits)} new commits, {len(files)} changed files",
flush=True,
)
@@ -1123,7 +1127,7 @@ class FollowupContextGatherer:
self.pr_number, self.previous_review.reviewed_at
)
except Exception as e:
print(f"[Followup] Error fetching comments: {e}", flush=True)
safe_print(f"[Followup] Error fetching comments: {e}")
comments = {"review_comments": [], "issue_comments": []}
# Get formal PR reviews since last review (from Cursor, CodeRabbit, etc.)
@@ -1132,7 +1136,7 @@ class FollowupContextGatherer:
self.pr_number, self.previous_review.reviewed_at
)
except Exception as e:
print(f"[Followup] Error fetching PR reviews: {e}", flush=True)
safe_print(f"[Followup] Error fetching PR reviews: {e}")
pr_reviews = []
# Separate AI bot comments from contributor comments
@@ -1179,7 +1183,7 @@ class FollowupContextGatherer:
contributor_reviews
)
print(
safe_print(
f"[Followup] Found {total_contributor_feedback} contributor feedback "
f"({len(contributor_comments)} comments, {len(contributor_reviews)} reviews), "
f"{total_ai_feedback} AI feedback "
@@ -1200,12 +1204,12 @@ class FollowupContextGatherer:
has_merge_conflicts = mergeable == "CONFLICTING"
if has_merge_conflicts:
print(
safe_print(
f"[Followup] ⚠️ PR has merge conflicts (mergeStateStatus: {merge_state_status})",
flush=True,
)
except Exception as e:
print(f"[Followup] Could not fetch merge status: {e}", flush=True)
safe_print(f"[Followup] Could not fetch merge status: {e}")
return FollowupReviewContext(
pr_number=self.pr_number,
+26 -24
View File
@@ -46,6 +46,7 @@ try:
PRReviewEngine,
TriageEngine,
)
from .services.io_utils import safe_print
except (ImportError, ValueError, SystemError):
# When imported directly (runner.py adds github dir to path)
from bot_detection import BotDetector
@@ -74,6 +75,7 @@ except (ImportError, ValueError, SystemError):
PRReviewEngine,
TriageEngine,
)
from services.io_utils import safe_print
@dataclass
@@ -267,12 +269,12 @@ class GitHubOrchestrator:
comment_id=triage.comment_id,
body=triage.response_comment,
)
print(
safe_print(
f"[AI TRIAGE] Posted reply to {triage.tool_name} comment {triage.comment_id}",
flush=True,
)
except Exception as e:
print(
safe_print(
f"[AI TRIAGE] Failed to post reply to comment {triage.comment_id}: {e}",
flush=True,
)
@@ -295,7 +297,7 @@ class GitHubOrchestrator:
Returns:
PRReviewResult with findings and overall assessment
"""
print(
safe_print(
f"[DEBUG orchestrator] review_pr() called for PR #{pr_number}", flush=True
)
@@ -308,14 +310,14 @@ class GitHubOrchestrator:
try:
# Gather PR context
print("[DEBUG orchestrator] Creating context gatherer...", flush=True)
safe_print("[DEBUG orchestrator] Creating context gatherer...")
gatherer = PRContextGatherer(
self.project_dir, pr_number, repo=self.config.repo
)
print("[DEBUG orchestrator] Gathering PR context...", flush=True)
safe_print("[DEBUG orchestrator] Gathering PR context...")
pr_context = await gatherer.gather()
print(
safe_print(
f"[DEBUG orchestrator] Context gathered: {pr_context.title} "
f"({len(pr_context.changed_files)} files, {len(pr_context.related_files)} related)",
flush=True,
@@ -331,14 +333,14 @@ class GitHubOrchestrator:
# Allow forcing a review to bypass "already reviewed" check
if should_skip and force_review and "Already reviewed" in skip_reason:
print(
safe_print(
f"[BOT DETECTION] Force review requested - bypassing: {skip_reason}",
flush=True,
)
should_skip = False
if should_skip:
print(
safe_print(
f"[BOT DETECTION] Skipping PR #{pr_number}: {skip_reason}",
flush=True,
)
@@ -348,7 +350,7 @@ class GitHubOrchestrator:
if "Already reviewed" in skip_reason:
existing_review = PRReviewResult.load(self.github_dir, pr_number)
if existing_review:
print(
safe_print(
"[BOT DETECTION] Returning existing review (no new commits)",
flush=True,
)
@@ -373,14 +375,14 @@ class GitHubOrchestrator:
)
# Delegate to PR Review Engine
print("[DEBUG orchestrator] Running multi-pass review...", flush=True)
safe_print("[DEBUG orchestrator] Running multi-pass review...")
(
findings,
structural_issues,
ai_triages,
quick_scan,
) = await self.pr_review_engine.run_multi_pass_review(pr_context)
print(
safe_print(
f"[DEBUG orchestrator] Multi-pass review complete: "
f"{len(findings)} findings, {len(structural_issues)} structural, {len(ai_triages)} AI triages",
flush=True,
@@ -407,12 +409,12 @@ class GitHubOrchestrator:
ci_log_parts.append(f"{pending_without_awaiting} pending")
if awaiting > 0:
ci_log_parts.append(f"{awaiting} awaiting approval")
print(
safe_print(
f"[orchestrator] CI status: {', '.join(ci_log_parts)}",
flush=True,
)
if awaiting > 0:
print(
safe_print(
f"[orchestrator] ⚠️ {awaiting} workflow(s) from fork need maintainer approval to run",
flush=True,
)
@@ -426,7 +428,7 @@ class GitHubOrchestrator:
has_merge_conflicts=pr_context.has_merge_conflicts,
merge_state_status=pr_context.merge_state_status,
)
print(
safe_print(
f"[DEBUG orchestrator] Verdict: {verdict.value} - {verdict_reasoning}",
flush=True,
)
@@ -471,12 +473,12 @@ class GitHubOrchestrator:
blob_sha = file.get("sha", "")
if filename and blob_sha:
file_blobs[filename] = blob_sha
print(
safe_print(
f"[Review] Captured {len(file_blobs)} file blob SHAs for follow-up tracking",
flush=True,
)
except Exception as e:
print(
safe_print(
f"[Review] Warning: Could not capture file blobs: {e}", flush=True
)
@@ -544,11 +546,11 @@ class GitHubOrchestrator:
# Log full exception details for debugging
error_details = f"{type(e).__name__}: {e}"
full_traceback = traceback.format_exc()
print(
safe_print(
f"[ERROR orchestrator] PR review failed for #{pr_number}: {error_details}",
flush=True,
)
print(f"[ERROR orchestrator] Full traceback:\n{full_traceback}", flush=True)
safe_print(f"[ERROR orchestrator] Full traceback:\n{full_traceback}")
result = PRReviewResult(
pr_number=pr_number,
@@ -577,7 +579,7 @@ class GitHubOrchestrator:
Raises:
ValueError: If no previous review exists for this PR
"""
print(
safe_print(
f"[DEBUG orchestrator] followup_review_pr() called for PR #{pr_number}",
flush=True,
)
@@ -622,7 +624,7 @@ class GitHubOrchestrator:
# Check if context gathering failed
if followup_context.error:
print(
safe_print(
f"[Followup] Context gathering failed: {followup_context.error}",
flush=True,
)
@@ -653,7 +655,7 @@ class GitHubOrchestrator:
if not has_commits and not has_file_changes:
base_sha = previous_review.reviewed_commit_sha[:8]
print(
safe_print(
f"[Followup] No changes since last review at {base_sha}",
flush=True,
)
@@ -700,7 +702,7 @@ class GitHubOrchestrator:
# Use parallel orchestrator for follow-up if enabled
if self.config.use_parallel_orchestrator:
print(
safe_print(
"[AI] Using parallel orchestrator for follow-up review (SDK subagents)...",
flush=True,
)
@@ -746,7 +748,7 @@ class GitHubOrchestrator:
# (CI status was already passed to AI via followup_context.ci_status)
failed_checks = followup_context.ci_status.get("failed_checks", [])
if failed_checks:
print(
safe_print(
f"[Followup] CI checks failing: {failed_checks}",
flush=True,
)
@@ -1259,7 +1261,7 @@ class GitHubOrchestrator:
issue["number"], result.labels_to_remove
)
except Exception as e:
print(f"Failed to apply labels to #{issue['number']}: {e}")
safe_print(f"Failed to apply labels to #{issue['number']}: {e}")
# Save result
await result.save(self.github_dir)
+144 -119
View File
@@ -65,6 +65,11 @@ env_file = Path(__file__).parent.parent.parent / ".env"
if env_file.exists():
load_dotenv(env_file)
# Initialize Sentry early to capture any startup errors
from core.sentry import capture_exception, init_sentry, set_context
init_sentry(component="github-runner")
from debug import debug_error
# Add github runner directory to path for direct imports
@@ -73,6 +78,7 @@ sys.path.insert(0, str(Path(__file__).parent))
# Now import models and orchestrator directly (they use relative imports internally)
from models import GitHubRunnerConfig
from orchestrator import GitHubOrchestrator, ProgressCallback
from services.io_utils import safe_print
def print_progress(callback: ProgressCallback) -> None:
@@ -83,7 +89,7 @@ def print_progress(callback: ProgressCallback) -> None:
elif callback.issue_number:
prefix = f"[Issue #{callback.issue_number}] "
print(f"{prefix}[{callback.progress:3d}%] {callback.message}", flush=True)
safe_print(f"{prefix}[{callback.progress:3d}%] {callback.message}")
def get_config(args) -> GitHubRunnerConfig:
@@ -110,8 +116,8 @@ def get_config(args) -> GitHubRunnerConfig:
break
if os.environ.get("DEBUG"):
print(f"[DEBUG] gh CLI path: {gh_path}", flush=True)
print(
safe_print(f"[DEBUG] gh CLI path: {gh_path}")
safe_print(
f"[DEBUG] PATH env: {os.environ.get('PATH', 'NOT SET')[:200]}...",
flush=True,
)
@@ -149,16 +155,20 @@ def get_config(args) -> GitHubRunnerConfig:
if result.returncode == 0:
repo = result.stdout.strip()
elif os.environ.get("DEBUG"):
print(f"[DEBUG] gh repo view failed: {result.stderr}", flush=True)
safe_print(f"[DEBUG] gh repo view failed: {result.stderr}")
except FileNotFoundError:
pass # gh not installed or not in PATH
if not token:
print("Error: No GitHub token found. Set GITHUB_TOKEN or run 'gh auth login'")
safe_print(
"Error: No GitHub token found. Set GITHUB_TOKEN or run 'gh auth login'"
)
sys.exit(1)
if not repo:
print("Error: No GitHub repo found. Set GITHUB_REPO or run from a git repo.")
safe_print(
"Error: No GitHub repo found. Set GITHUB_REPO or run from a git repo."
)
sys.exit(1)
return GitHubRunnerConfig(
@@ -185,18 +195,18 @@ async def cmd_review_pr(args) -> int:
debug = os.environ.get("DEBUG")
if debug:
print(f"[DEBUG] Starting PR review for PR #{args.pr_number}", flush=True)
print(f"[DEBUG] Project directory: {args.project}", flush=True)
print("[DEBUG] Building config...", flush=True)
safe_print(f"[DEBUG] Starting PR review for PR #{args.pr_number}")
safe_print(f"[DEBUG] Project directory: {args.project}")
safe_print("[DEBUG] Building config...")
config = get_config(args)
if debug:
print(
safe_print(
f"[DEBUG] Config built: repo={config.repo}, model={config.model}",
flush=True,
)
print("[DEBUG] Creating orchestrator...", flush=True)
safe_print("[DEBUG] Creating orchestrator...")
orchestrator = GitHubOrchestrator(
project_dir=args.project,
@@ -205,8 +215,8 @@ async def cmd_review_pr(args) -> int:
)
if debug:
print("[DEBUG] Orchestrator created", flush=True)
print(
safe_print("[DEBUG] Orchestrator created")
safe_print(
f"[DEBUG] Calling orchestrator.review_pr({args.pr_number})...", flush=True
)
@@ -215,27 +225,27 @@ async def cmd_review_pr(args) -> int:
result = await orchestrator.review_pr(args.pr_number, force_review=force_review)
if debug:
print(f"[DEBUG] review_pr returned, success={result.success}", flush=True)
safe_print(f"[DEBUG] review_pr returned, success={result.success}")
if result.success:
print(f"\n{'=' * 60}")
print(f"PR #{result.pr_number} Review Complete")
print(f"{'=' * 60}")
print(f"Status: {result.overall_status}")
print(f"Summary: {result.summary}")
print(f"Findings: {len(result.findings)}")
safe_print(f"\n{'=' * 60}")
safe_print(f"PR #{result.pr_number} Review Complete")
safe_print(f"{'=' * 60}")
safe_print(f"Status: {result.overall_status}")
safe_print(f"Summary: {result.summary}")
safe_print(f"Findings: {len(result.findings)}")
if result.findings:
print("\nFindings by severity:")
safe_print("\nFindings by severity:")
for f in result.findings:
emoji = {"critical": "!", "high": "*", "medium": "-", "low": "."}
print(
safe_print(
f" {emoji.get(f.severity.value, '?')} [{f.severity.value.upper()}] {f.title}"
)
print(f" File: {f.file}:{f.line}")
safe_print(f" File: {f.file}:{f.line}")
return 0
else:
print(f"\nReview failed: {result.error}")
safe_print(f"\nReview failed: {result.error}")
return 1
@@ -251,18 +261,18 @@ async def cmd_followup_review_pr(args) -> int:
debug = os.environ.get("DEBUG")
if debug:
print(f"[DEBUG] Starting follow-up review for PR #{args.pr_number}", flush=True)
print(f"[DEBUG] Project directory: {args.project}", flush=True)
print("[DEBUG] Building config...", flush=True)
safe_print(f"[DEBUG] Starting follow-up review for PR #{args.pr_number}")
safe_print(f"[DEBUG] Project directory: {args.project}")
safe_print("[DEBUG] Building config...")
config = get_config(args)
if debug:
print(
safe_print(
f"[DEBUG] Config built: repo={config.repo}, model={config.model}",
flush=True,
)
print("[DEBUG] Creating orchestrator...", flush=True)
safe_print("[DEBUG] Creating orchestrator...")
orchestrator = GitHubOrchestrator(
project_dir=args.project,
@@ -271,8 +281,8 @@ async def cmd_followup_review_pr(args) -> int:
)
if debug:
print("[DEBUG] Orchestrator created", flush=True)
print(
safe_print("[DEBUG] Orchestrator created")
safe_print(
f"[DEBUG] Calling orchestrator.followup_review_pr({args.pr_number})...",
flush=True,
)
@@ -280,43 +290,43 @@ async def cmd_followup_review_pr(args) -> int:
try:
result = await orchestrator.followup_review_pr(args.pr_number)
except ValueError as e:
print(f"\nFollow-up review failed: {e}")
safe_print(f"\nFollow-up review failed: {e}")
return 1
if debug:
print(
safe_print(
f"[DEBUG] followup_review_pr returned, success={result.success}", flush=True
)
if result.success:
print(f"\n{'=' * 60}")
print(f"PR #{result.pr_number} Follow-up Review Complete")
print(f"{'=' * 60}")
print(f"Status: {result.overall_status}")
print(f"Is Follow-up: {result.is_followup_review}")
safe_print(f"\n{'=' * 60}")
safe_print(f"PR #{result.pr_number} Follow-up Review Complete")
safe_print(f"{'=' * 60}")
safe_print(f"Status: {result.overall_status}")
safe_print(f"Is Follow-up: {result.is_followup_review}")
if result.resolved_findings:
print(f"Resolved: {len(result.resolved_findings)} finding(s)")
safe_print(f"Resolved: {len(result.resolved_findings)} finding(s)")
if result.unresolved_findings:
print(f"Still Open: {len(result.unresolved_findings)} finding(s)")
safe_print(f"Still Open: {len(result.unresolved_findings)} finding(s)")
if result.new_findings_since_last_review:
print(
safe_print(
f"New Issues: {len(result.new_findings_since_last_review)} finding(s)"
)
print(f"\nSummary:\n{result.summary}")
safe_print(f"\nSummary:\n{result.summary}")
if result.findings:
print("\nRemaining Findings:")
safe_print("\nRemaining Findings:")
for f in result.findings:
emoji = {"critical": "!", "high": "*", "medium": "-", "low": "."}
print(
safe_print(
f" {emoji.get(f.severity.value, '?')} [{f.severity.value.upper()}] {f.title}"
)
print(f" File: {f.file}:{f.line}")
safe_print(f" File: {f.file}:{f.line}")
return 0
else:
print(f"\nFollow-up review failed: {result.error}")
safe_print(f"\nFollow-up review failed: {result.error}")
return 1
@@ -335,9 +345,9 @@ async def cmd_triage(args) -> int:
apply_labels=args.apply_labels,
)
print(f"\n{'=' * 60}")
print(f"Triaged {len(results)} issues")
print(f"{'=' * 60}")
safe_print(f"\n{'=' * 60}")
safe_print(f"Triaged {len(results)} issues")
safe_print(f"{'=' * 60}")
for r in results:
flags = []
@@ -349,12 +359,12 @@ async def cmd_triage(args) -> int:
flags.append("CREEP")
flag_str = f" [{', '.join(flags)}]" if flags else ""
print(
safe_print(
f" #{r.issue_number}: {r.category.value} (confidence: {r.confidence:.0%}){flag_str}"
)
if r.labels_to_add:
print(f" + Labels: {', '.join(r.labels_to_add)}")
safe_print(f" + Labels: {', '.join(r.labels_to_add)}")
return 0
@@ -371,16 +381,16 @@ async def cmd_auto_fix(args) -> int:
state = await orchestrator.auto_fix_issue(args.issue_number)
print(f"\n{'=' * 60}")
print(f"Auto-Fix State for Issue #{state.issue_number}")
print(f"{'=' * 60}")
print(f"Status: {state.status.value}")
safe_print(f"\n{'=' * 60}")
safe_print(f"Auto-Fix State for Issue #{state.issue_number}")
safe_print(f"{'=' * 60}")
safe_print(f"Status: {state.status.value}")
if state.spec_id:
print(f"Spec ID: {state.spec_id}")
safe_print(f"Spec ID: {state.spec_id}")
if state.pr_number:
print(f"PR: #{state.pr_number}")
safe_print(f"PR: #{state.pr_number}")
if state.error:
print(f"Error: {state.error}")
safe_print(f"Error: {state.error}")
return 0
@@ -398,11 +408,11 @@ async def cmd_check_labels(args) -> int:
issues = await orchestrator.check_auto_fix_labels()
if issues:
print(f"Found {len(issues)} issues with auto-fix labels:")
safe_print(f"Found {len(issues)} issues with auto-fix labels:")
for num in issues:
print(f" #{num}")
safe_print(f" #{num}")
else:
print("No issues with auto-fix labels found.")
safe_print("No issues with auto-fix labels found.")
return 0
@@ -419,8 +429,8 @@ async def cmd_check_new(args) -> int:
issues = await orchestrator.check_new_issues()
print("JSON Output")
print(json.dumps(issues))
safe_print("JSON Output")
safe_print(json.dumps(issues))
return 0
@@ -435,12 +445,12 @@ async def cmd_queue(args) -> int:
queue = await orchestrator.get_auto_fix_queue()
print(f"\n{'=' * 60}")
print(f"Auto-Fix Queue ({len(queue)} items)")
print(f"{'=' * 60}")
safe_print(f"\n{'=' * 60}")
safe_print(f"Auto-Fix Queue ({len(queue)} items)")
safe_print(f"{'=' * 60}")
if not queue:
print("Queue is empty.")
safe_print("Queue is empty.")
return 0
for state in queue:
@@ -455,11 +465,11 @@ async def cmd_queue(args) -> int:
"failed": "ERR",
}
emoji = status_emoji.get(state.status.value, "???")
print(f" [{emoji}] #{state.issue_number}: {state.status.value}")
safe_print(f" [{emoji}] #{state.issue_number}: {state.status.value}")
if state.pr_number:
print(f" PR: #{state.pr_number}")
safe_print(f" PR: #{state.pr_number}")
if state.error:
print(f" Error: {state.error[:50]}...")
safe_print(f" Error: {state.error[:50]}...")
return 0
@@ -477,22 +487,24 @@ async def cmd_batch_issues(args) -> int:
issue_numbers = args.issues if args.issues else None
batches = await orchestrator.batch_and_fix_issues(issue_numbers)
print(f"\n{'=' * 60}")
print(f"Created {len(batches)} batches from similar issues")
print(f"{'=' * 60}")
safe_print(f"\n{'=' * 60}")
safe_print(f"Created {len(batches)} batches from similar issues")
safe_print(f"{'=' * 60}")
if not batches:
print("No batches created. Either no issues found or all issues are unique.")
safe_print(
"No batches created. Either no issues found or all issues are unique."
)
return 0
for batch in batches:
issue_nums = ", ".join(f"#{i.issue_number}" for i in batch.issues)
print(f"\n Batch: {batch.batch_id}")
print(f" Issues: {issue_nums}")
print(f" Theme: {batch.theme}")
print(f" Status: {batch.status.value}")
safe_print(f"\n Batch: {batch.batch_id}")
safe_print(f" Issues: {issue_nums}")
safe_print(f" Theme: {batch.theme}")
safe_print(f" Status: {batch.status.value}")
if batch.spec_id:
print(f" Spec: {batch.spec_id}")
safe_print(f" Spec: {batch.spec_id}")
return 0
@@ -507,14 +519,14 @@ async def cmd_batch_status(args) -> int:
status = await orchestrator.get_batch_status()
print(f"\n{'=' * 60}")
print("Batch Status")
print(f"{'=' * 60}")
print(f"Total batches: {status.get('total_batches', 0)}")
print(f"Pending: {status.get('pending', 0)}")
print(f"Processing: {status.get('processing', 0)}")
print(f"Completed: {status.get('completed', 0)}")
print(f"Failed: {status.get('failed', 0)}")
safe_print(f"\n{'=' * 60}")
safe_print("Batch Status")
safe_print(f"{'=' * 60}")
safe_print(f"Total batches: {status.get('total_batches', 0)}")
safe_print(f"Pending: {status.get('pending', 0)}")
safe_print(f"Processing: {status.get('processing', 0)}")
safe_print(f"Completed: {status.get('completed', 0)}")
safe_print(f"Failed: {status.get('failed', 0)}")
return 0
@@ -543,47 +555,47 @@ async def cmd_analyze_preview(args) -> int:
)
if not result.get("success"):
print(f"Error: {result.get('error', 'Unknown error')}")
safe_print(f"Error: {result.get('error', 'Unknown error')}")
return 1
print(f"\n{'=' * 60}")
print("Issue Analysis Preview")
print(f"{'=' * 60}")
print(f"Total issues: {result.get('total_issues', 0)}")
print(f"Analyzed: {result.get('analyzed_issues', 0)}")
print(f"Already batched: {result.get('already_batched', 0)}")
print(f"Proposed batches: {len(result.get('proposed_batches', []))}")
print(f"Single issues: {len(result.get('single_issues', []))}")
safe_print(f"\n{'=' * 60}")
safe_print("Issue Analysis Preview")
safe_print(f"{'=' * 60}")
safe_print(f"Total issues: {result.get('total_issues', 0)}")
safe_print(f"Analyzed: {result.get('analyzed_issues', 0)}")
safe_print(f"Already batched: {result.get('already_batched', 0)}")
safe_print(f"Proposed batches: {len(result.get('proposed_batches', []))}")
safe_print(f"Single issues: {len(result.get('single_issues', []))}")
proposed_batches = result.get("proposed_batches", [])
if proposed_batches:
print(f"\n{'=' * 60}")
print("Proposed Batches (for human review)")
print(f"{'=' * 60}")
safe_print(f"\n{'=' * 60}")
safe_print("Proposed Batches (for human review)")
safe_print(f"{'=' * 60}")
for i, batch in enumerate(proposed_batches, 1):
confidence = batch.get("confidence", 0)
validated = "" if batch.get("validated") else "[NEEDS REVIEW] "
print(
safe_print(
f"\n Batch {i}: {validated}{batch.get('theme', 'No theme')} ({confidence:.0%} confidence)"
)
print(f" Primary issue: #{batch.get('primary_issue')}")
print(f" Issue count: {batch.get('issue_count', 0)}")
print(f" Reasoning: {batch.get('reasoning', 'N/A')}")
print(" Issues:")
safe_print(f" Primary issue: #{batch.get('primary_issue')}")
safe_print(f" Issue count: {batch.get('issue_count', 0)}")
safe_print(f" Reasoning: {batch.get('reasoning', 'N/A')}")
safe_print(" Issues:")
for item in batch.get("issues", []):
similarity = item.get("similarity_to_primary", 0)
print(
safe_print(
f" - #{item['issue_number']}: {item.get('title', '?')} ({similarity:.0%})"
)
# Output JSON for programmatic use
if getattr(args, "json", False):
print(f"\n{'=' * 60}")
print("JSON Output")
print(f"{'=' * 60}")
safe_print(f"\n{'=' * 60}")
safe_print("JSON Output")
safe_print(f"{'=' * 60}")
# Print JSON on single line to avoid corruption from line-by-line stdout prefixes
print(json.dumps(result))
safe_print(json.dumps(result))
return 0
@@ -608,24 +620,24 @@ async def cmd_approve_batches(args) -> int:
with open(args.batch_file) as f:
approved_batches = json.load(f)
except (json.JSONDecodeError, FileNotFoundError) as e:
print(f"Error loading batch file: {e}")
safe_print(f"Error loading batch file: {e}")
return 1
if not approved_batches:
print("No batches in file to approve.")
safe_print("No batches in file to approve.")
return 0
print(f"Approving and executing {len(approved_batches)} batches...")
safe_print(f"Approving and executing {len(approved_batches)} batches...")
created_batches = await orchestrator.approve_and_execute_batches(approved_batches)
print(f"\n{'=' * 60}")
print(f"Created {len(created_batches)} batches")
print(f"{'=' * 60}")
safe_print(f"\n{'=' * 60}")
safe_print(f"Created {len(created_batches)} batches")
safe_print(f"{'=' * 60}")
for batch in created_batches:
issue_nums = ", ".join(f"#{i.issue_number}" for i in batch.issues)
print(f" {batch.batch_id}: {issue_nums}")
safe_print(f" {batch.batch_id}: {issue_nums}")
return 0
@@ -800,20 +812,33 @@ def main():
handler = commands.get(args.command)
if not handler:
print(f"Unknown command: {args.command}")
safe_print(f"Unknown command: {args.command}")
sys.exit(1)
try:
# Set context for Sentry
set_context(
"command",
{
"name": args.command,
"project": str(args.project),
"repo": args.repo or "auto-detect",
},
)
exit_code = asyncio.run(handler(args))
sys.exit(exit_code)
except KeyboardInterrupt:
print("\nInterrupted.")
safe_print("\nInterrupted.")
sys.exit(1)
except Exception as e:
import traceback
# Capture exception with Sentry
capture_exception(e, command=args.command)
debug_error("github_runner", "Command failed", error=str(e))
print(f"Error: {e}")
safe_print(f"Error: {e}")
traceback.print_exc()
sys.exit(1)
@@ -3,14 +3,23 @@ GitHub Orchestrator Services
============================
Service layer for GitHub automation workflows.
NOTE: Uses lazy imports to avoid circular dependency with context_gatherer.py.
The circular import chain was: orchestrator context_gatherer services.io_utils
services/__init__ pr_review_engine context_gatherer (circular!)
"""
from .autofix_processor import AutoFixProcessor
from .batch_processor import BatchProcessor
from .pr_review_engine import PRReviewEngine
from .prompt_manager import PromptManager
from .response_parsers import ResponseParser
from .triage_engine import TriageEngine
from __future__ import annotations
# Lazy import mapping - classes are loaded on first access
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"AutoFixProcessor": (".autofix_processor", "AutoFixProcessor"),
"BatchProcessor": (".batch_processor", "BatchProcessor"),
"PRReviewEngine": (".pr_review_engine", "PRReviewEngine"),
"PromptManager": (".prompt_manager", "PromptManager"),
"ResponseParser": (".response_parsers", "ResponseParser"),
"TriageEngine": (".triage_engine", "TriageEngine"),
}
__all__ = [
"PromptManager",
@@ -20,3 +29,19 @@ __all__ = [
"AutoFixProcessor",
"BatchProcessor",
]
# Cache for lazily loaded modules
_loaded: dict[str, object] = {}
def __getattr__(name: str) -> object:
"""Lazy import handler - loads classes on first access."""
if name in _LAZY_IMPORTS:
if name not in _loaded:
module_name, attr_name = _LAZY_IMPORTS[name]
import importlib
module = importlib.import_module(module_name, __name__)
_loaded[name] = getattr(module, attr_name)
return _loaded[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -12,8 +12,10 @@ from pathlib import Path
try:
from ..models import AutoFixState, AutoFixStatus, GitHubRunnerConfig
from .io_utils import safe_print
except (ImportError, ValueError, SystemError):
from models import AutoFixState, AutoFixStatus, GitHubRunnerConfig
from services.io_utils import safe_print
class BatchProcessor:
@@ -76,10 +78,10 @@ class BatchProcessor:
try:
if not issues:
print("[BATCH] No issues to batch", flush=True)
safe_print("[BATCH] No issues to batch")
return []
print(
safe_print(
f"[BATCH] Analyzing {len(issues)} issues for similarity...", flush=True
)
@@ -123,7 +125,7 @@ class BatchProcessor:
# Create batches (includes AI validation)
batches = await batcher.create_batches(issues, exclude_issues)
print(f"[BATCH] Created {len(batches)} validated batches", flush=True)
safe_print(f"[BATCH] Created {len(batches)} validated batches")
self._report_progress("batching", 60, f"Created {len(batches)} batches")
@@ -137,7 +139,7 @@ class BatchProcessor:
f"Processing batch {i + 1}/{len(batches)} ({len(issue_nums)} issues)...",
)
print(
safe_print(
f"[BATCH] Batch {batch.batch_id}: {len(issue_nums)} issues - {issue_nums}",
flush=True,
)
@@ -164,7 +166,7 @@ class BatchProcessor:
return batches
except Exception as e:
print(f"[BATCH] Error batching issues: {e}", flush=True)
safe_print(f"[BATCH] Error batching issues: {e}")
import traceback
traceback.print_exc()
@@ -204,7 +206,7 @@ class BatchProcessor:
issues = issues[:max_issues]
print(
safe_print(
f"[PREVIEW] Analyzing {len(issues)} issues for grouping...", flush=True
)
self._report_progress("analyzing", 20, f"Analyzing {len(issues)} issues...")
@@ -343,7 +345,7 @@ class BatchProcessor:
reasoning = result.reasoning
refined_theme = result.common_theme or refined_theme
except Exception as e:
print(f"[PREVIEW] Validation error: {e}", flush=True)
safe_print(f"[PREVIEW] Validation error: {e}")
validated = True
confidence = 0.5
reasoning = "Validation skipped due to error"
@@ -380,7 +382,7 @@ class BatchProcessor:
except Exception as e:
import traceback
print(f"[PREVIEW] Error: {e}", flush=True)
safe_print(f"[PREVIEW] Error: {e}")
traceback.print_exc()
return {
"success": False,
@@ -35,6 +35,7 @@ try:
ReviewSeverity,
)
from .category_utils import map_category
from .io_utils import safe_print
from .prompt_manager import PromptManager
from .pydantic_models import FollowupReviewResponse
except (ImportError, ValueError, SystemError):
@@ -47,6 +48,7 @@ except (ImportError, ValueError, SystemError):
ReviewSeverity,
)
from services.category_utils import map_category
from services.io_utils import safe_print
from services.prompt_manager import PromptManager
from services.pydantic_models import FollowupReviewResponse
@@ -102,7 +104,7 @@ class FollowupReviewer:
"pr_number": pr_number,
}
)
print(f"[Followup] [{phase}] {message}", flush=True)
safe_print(f"[Followup] [{phase}] {message}")
async def review_followup(
self,
@@ -691,7 +693,7 @@ Analyze this follow-up review context and provide your structured response.
logger.debug(
f"[Followup] Using output_format schema: {list(schema.get('properties', {}).keys())}"
)
print(f"[Followup] SDK query with output_format, model={model}", flush=True)
safe_print(f"[Followup] SDK query with output_format, model={model}")
# Iterate through messages from the query
# Note: max_turns=2 because structured output uses a tool call + response
@@ -726,7 +728,7 @@ Analyze this follow-up review context and provide your structured response.
logger.info(
"[Followup] Found StructuredOutput tool use"
)
print(
safe_print(
"[Followup] Using SDK structured output",
flush=True,
)
@@ -744,7 +746,7 @@ Analyze this follow-up review context and provide your structured response.
logger.info(
"[Followup] Found structured_output attribute on message"
)
print(
safe_print(
"[Followup] Using SDK structured output (direct attribute)",
flush=True,
)
@@ -768,7 +770,7 @@ Analyze this follow-up review context and provide your structured response.
except ValueError as e:
# OAuth token not found
logger.warning(f"No OAuth token available for AI review: {e}")
print("AI review failed: No OAuth token found", flush=True)
safe_print("AI review failed: No OAuth token found")
return None
except Exception as e:
logger.error(f"AI review with structured output failed: {e}")
@@ -0,0 +1,14 @@
"""
I/O Utilities for GitHub Services
=================================
This module re-exports safe I/O utilities from core.io_utils for
backwards compatibility. New code should import directly from core.io_utils.
"""
from __future__ import annotations
# Re-export from core for backwards compatibility
from core.io_utils import is_pipe_broken, reset_pipe_state, safe_print
__all__ = ["safe_print", "is_pipe_broken", "reset_pipe_state"]
@@ -44,6 +44,7 @@ try:
ReviewSeverity,
)
from .category_utils import map_category
from .io_utils import safe_print
from .pr_worktree_manager import PRWorktreeManager
from .pydantic_models import ParallelFollowupResponse
from .sdk_utils import process_sdk_stream
@@ -62,6 +63,7 @@ except (ImportError, ValueError, SystemError):
)
from phase_config import get_thinking_budget
from services.category_utils import map_category
from services.io_utils import safe_print
from services.pr_worktree_manager import PRWorktreeManager
from services.pydantic_models import ParallelFollowupResponse
from services.sdk_utils import process_sdk_stream
@@ -456,7 +458,7 @@ The SDK will run invoked agents in parallel automatically.
if head_sha and _validate_git_ref(head_sha):
try:
if DEBUG_MODE:
print(
safe_print(
f"[Followup] DEBUG: Creating worktree for head_sha={head_sha}",
flush=True,
)
@@ -464,13 +466,13 @@ The SDK will run invoked agents in parallel automatically.
head_sha, context.pr_number
)
project_root = worktree_path
print(
safe_print(
f"[Followup] Using worktree at {worktree_path.name} for PR review",
flush=True,
)
except Exception as e:
if DEBUG_MODE:
print(
safe_print(
f"[Followup] DEBUG: Worktree creation FAILED: {e}",
flush=True,
)
@@ -520,7 +522,7 @@ The SDK will run invoked agents in parallel automatically.
async with client:
await client.query(prompt)
print(
safe_print(
f"[ParallelFollowup] Running orchestrator ({model})...",
flush=True,
)
@@ -572,7 +574,7 @@ The SDK will run invoked agents in parallel automatically.
logger.info(
f"[ParallelFollowup] Session complete. Agents invoked: {final_agents}"
)
print(
safe_print(
f"[ParallelFollowup] Complete. Agents invoked: {final_agents}",
flush=True,
)
@@ -601,7 +603,7 @@ The SDK will run invoked agents in parallel automatically.
"Blocked: PR has merge conflicts with base branch. "
"Resolve conflicts before merge."
)
print(
safe_print(
"[ParallelFollowup] ⚠️ PR has merge conflicts - blocking merge",
flush=True,
)
@@ -616,7 +618,7 @@ The SDK will run invoked agents in parallel automatically.
):
verdict = MergeVerdict.NEEDS_REVISION
verdict_reasoning = BRANCH_BEHIND_REASONING
print(
safe_print(
"[ParallelFollowup] ⚠️ PR branch is behind base - needs update",
flush=True,
)
@@ -711,7 +713,7 @@ The SDK will run invoked agents in parallel automatically.
except Exception as e:
logger.error(f"[ParallelFollowup] Review failed: {e}", exc_info=True)
print(f"[ParallelFollowup] Error: {e}", flush=True)
safe_print(f"[ParallelFollowup] Error: {e}")
return PRReviewResult(
pr_number=context.pr_number,
@@ -742,12 +744,12 @@ The SDK will run invoked agents in parallel automatically.
# Log agents from structured output
agents_from_output = response.agents_invoked or []
if agents_from_output:
print(
safe_print(
f"[ParallelFollowup] Specialist agents invoked: {', '.join(agents_from_output)}",
flush=True,
)
for agent in agents_from_output:
print(f"[Agent:{agent}] Analysis complete", flush=True)
safe_print(f"[Agent:{agent}] Analysis complete")
findings = []
resolved_ids = []
@@ -762,7 +764,7 @@ The SDK will run invoked agents in parallel automatically.
validation_map[fv.finding_id] = fv
if fv.validation_status == "dismissed_false_positive":
dismissed_ids.append(fv.finding_id)
print(
safe_print(
f"[ParallelFollowup] Finding {fv.finding_id} DISMISSED as false positive: {fv.explanation[:100]}",
flush=True,
)
@@ -774,7 +776,7 @@ The SDK will run invoked agents in parallel automatically.
# Check if finding was validated and dismissed as false positive
if rv.finding_id in dismissed_ids:
# Finding-validator determined this was a false positive - skip it
print(
safe_print(
f"[ParallelFollowup] Skipping {rv.finding_id} - dismissed as false positive by finding-validator",
flush=True,
)
@@ -887,27 +889,27 @@ The SDK will run invoked agents in parallel automatically.
)
# Log findings summary for verification
print(
safe_print(
f"[ParallelFollowup] Parsed {len(findings)} findings, "
f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved, "
f"{len(new_finding_ids)} new",
flush=True,
)
if dismissed_ids:
print(
safe_print(
f"[ParallelFollowup] Validation: {len(dismissed_ids)} findings dismissed as false positives, "
f"{confirmed_valid_count} confirmed valid, {needs_human_count} need human review",
flush=True,
)
if findings:
print("[ParallelFollowup] Findings summary:", flush=True)
safe_print("[ParallelFollowup] Findings summary:")
for i, f in enumerate(findings, 1):
validation_note = ""
if f.validation_status == "confirmed_valid":
validation_note = " [VALIDATED]"
elif f.validation_status == "needs_human_review":
validation_note = " [NEEDS HUMAN REVIEW]"
print(
safe_print(
f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line}){validation_note}",
flush=True,
)
@@ -40,6 +40,7 @@ try:
ReviewSeverity,
)
from .category_utils import map_category
from .io_utils import safe_print
from .pr_worktree_manager import PRWorktreeManager
from .pydantic_models import ParallelOrchestratorResponse
from .sdk_utils import process_sdk_stream
@@ -58,6 +59,7 @@ except (ImportError, ValueError, SystemError):
)
from phase_config import get_thinking_budget
from services.category_utils import map_category
from services.io_utils import safe_print
from services.pr_worktree_manager import PRWorktreeManager
from services.pydantic_models import ParallelOrchestratorResponse
from services.sdk_utils import process_sdk_stream
@@ -380,12 +382,12 @@ The SDK will run invoked agents in parallel automatically.
agents: List of agent names that were invoked
"""
if agents:
print(
safe_print(
f"[ParallelOrchestrator] Specialist agents invoked: {', '.join(agents)}",
flush=True,
)
for agent in agents:
print(f"[Agent:{agent}] Analysis complete", flush=True)
safe_print(f"[Agent:{agent}] Analysis complete")
def _log_findings_summary(self, findings: list[PRReviewFinding]) -> None:
"""Log findings summary for verification.
@@ -394,13 +396,13 @@ The SDK will run invoked agents in parallel automatically.
findings: List of findings to summarize
"""
if findings:
print(
safe_print(
f"[ParallelOrchestrator] Parsed {len(findings)} findings from structured output",
flush=True,
)
print("[ParallelOrchestrator] Findings summary:", flush=True)
safe_print("[ParallelOrchestrator] Findings summary:")
for i, f in enumerate(findings, 1):
print(
safe_print(
f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line})",
flush=True,
)
@@ -474,15 +476,15 @@ The SDK will run invoked agents in parallel automatically.
head_sha = context.head_sha or context.head_branch
if DEBUG_MODE:
print(
safe_print(
f"[PRReview] DEBUG: context.head_sha='{context.head_sha}'",
flush=True,
)
print(
safe_print(
f"[PRReview] DEBUG: context.head_branch='{context.head_branch}'",
flush=True,
)
print(f"[PRReview] DEBUG: resolved head_sha='{head_sha}'", flush=True)
safe_print(f"[PRReview] DEBUG: resolved head_sha='{head_sha}'")
# SECURITY: Validate the resolved head_sha (whether SHA or branch name)
# This catches invalid refs early before subprocess calls
@@ -495,7 +497,7 @@ The SDK will run invoked agents in parallel automatically.
if not head_sha:
if DEBUG_MODE:
print("[PRReview] DEBUG: No head_sha - using fallback", flush=True)
safe_print("[PRReview] DEBUG: No head_sha - using fallback")
logger.warning(
"[ParallelOrchestrator] No head_sha available, using current checkout"
)
@@ -507,7 +509,7 @@ The SDK will run invoked agents in parallel automatically.
)
else:
if DEBUG_MODE:
print(
safe_print(
f"[PRReview] DEBUG: Creating worktree for head_sha={head_sha}",
flush=True,
)
@@ -517,14 +519,14 @@ The SDK will run invoked agents in parallel automatically.
)
project_root = worktree_path
if DEBUG_MODE:
print(
safe_print(
f"[PRReview] DEBUG: Using worktree as "
f"project_root={project_root}",
flush=True,
)
except (RuntimeError, ValueError) as e:
if DEBUG_MODE:
print(
safe_print(
f"[PRReview] DEBUG: Worktree creation FAILED: {e}",
flush=True,
)
@@ -564,7 +566,7 @@ The SDK will run invoked agents in parallel automatically.
async with client:
await client.query(prompt)
print(
safe_print(
f"[ParallelOrchestrator] Running orchestrator ({model})...",
flush=True,
)
@@ -608,7 +610,7 @@ The SDK will run invoked agents in parallel automatically.
logger.info(
f"[ParallelOrchestrator] Session complete. Agents invoked: {final_agents}"
)
print(
safe_print(
f"[ParallelOrchestrator] Complete. Agents invoked: {final_agents}",
flush=True,
)
@@ -21,6 +21,7 @@ try:
ReviewPass,
StructuralIssue,
)
from .io_utils import safe_print
from .prompt_manager import PromptManager
from .response_parsers import ResponseParser
except (ImportError, ValueError, SystemError):
@@ -32,6 +33,7 @@ except (ImportError, ValueError, SystemError):
ReviewPass,
StructuralIssue,
)
from services.io_utils import safe_print
from services.prompt_manager import PromptManager
from services.response_parsers import ResponseParser
@@ -80,19 +82,19 @@ class PRReviewEngine:
total_changes = context.total_additions + context.total_deletions
if total_changes > 200:
print(
safe_print(
f"[AI] Deep analysis needed: {total_changes} lines changed", flush=True
)
return True
complexity = scan_result.get("complexity", "low")
if complexity in ["high", "medium"]:
print(f"[AI] Deep analysis needed: {complexity} complexity", flush=True)
safe_print(f"[AI] Deep analysis needed: {complexity} complexity")
return True
risk_areas = scan_result.get("risk_areas", [])
if risk_areas:
print(
safe_print(
f"[AI] Deep analysis needed: {len(risk_areas)} risk areas", flush=True
)
return True
@@ -111,7 +113,7 @@ class PRReviewEngine:
seen.add(key)
unique.append(f)
else:
print(
safe_print(
f"[AI] Skipping duplicate finding: {f.file}:{f.line} - {f.title}",
flush=True,
)
@@ -171,7 +173,7 @@ class PRReviewEngine:
# If diff is empty/truncated, build composite from individual file patches
if context.diff_truncated or not context.diff:
print(
safe_print(
f"[AI] Building composite diff from {len(context.changed_files)} file patches...",
flush=True,
)
@@ -260,7 +262,7 @@ class PRReviewEngine:
error_msg = f"Review pass {review_pass.value} failed: {e}"
logger.error(error_msg)
logger.error(f"Traceback: {traceback.format_exc()}")
print(f"[AI] ERROR: {error_msg}", flush=True)
safe_print(f"[AI] ERROR: {error_msg}")
# Re-raise to allow caller to handle or track partial failures
raise RuntimeError(error_msg) from e
@@ -281,7 +283,7 @@ class PRReviewEngine:
"""
# Use parallel orchestrator with SDK subagents if enabled
if self.config.use_parallel_orchestrator:
print(
safe_print(
"[AI] Using parallel orchestrator PR review (SDK subagents)...",
flush=True,
)
@@ -303,7 +305,7 @@ class PRReviewEngine:
result = await orchestrator.review(context)
print(
safe_print(
f"[PR Review Engine] Parallel orchestrator returned {len(result.findings)} findings",
flush=True,
)
@@ -322,7 +324,7 @@ class PRReviewEngine:
ai_triages = []
# Pass 1: Quick Scan (must run first - determines if deep analysis needed)
print("[AI] Pass 1/6: Quick Scan - Understanding scope...", flush=True)
safe_print("[AI] Pass 1/6: Quick Scan - Understanding scope...")
self._report_progress(
"analyzing",
35,
@@ -339,7 +341,7 @@ class PRReviewEngine:
parallel_tasks = []
task_names = []
print("[AI] Running passes 2-6 in parallel...", flush=True)
safe_print("[AI] Running passes 2-6 in parallel...")
self._report_progress(
"analyzing",
50,
@@ -348,50 +350,50 @@ class PRReviewEngine:
)
async def run_security_pass():
print(
safe_print(
"[AI] Pass 2/6: Security Review - Analyzing vulnerabilities...",
flush=True,
)
findings = await self.run_review_pass(ReviewPass.SECURITY, context)
print(f"[AI] Security pass complete: {len(findings)} findings", flush=True)
safe_print(f"[AI] Security pass complete: {len(findings)} findings")
return ("security", findings)
async def run_quality_pass():
print(
safe_print(
"[AI] Pass 3/6: Quality Review - Checking code quality...", flush=True
)
findings = await self.run_review_pass(ReviewPass.QUALITY, context)
print(f"[AI] Quality pass complete: {len(findings)} findings", flush=True)
safe_print(f"[AI] Quality pass complete: {len(findings)} findings")
return ("quality", findings)
async def run_structural_pass():
print(
safe_print(
"[AI] Pass 4/6: Structural Review - Checking for feature creep...",
flush=True,
)
result_text = await self._run_structural_pass(context)
issues = self.parser.parse_structural_issues(result_text)
print(f"[AI] Structural pass complete: {len(issues)} issues", flush=True)
safe_print(f"[AI] Structural pass complete: {len(issues)} issues")
return ("structural", issues)
async def run_ai_triage_pass():
print(
safe_print(
"[AI] Pass 5/6: AI Comment Triage - Verifying other AI comments...",
flush=True,
)
result_text = await self._run_ai_triage_pass(context)
triages = self.parser.parse_ai_comment_triages(result_text)
print(
safe_print(
f"[AI] AI triage complete: {len(triages)} comments triaged", flush=True
)
return ("ai_triage", triages)
async def run_deep_pass():
print(
safe_print(
"[AI] Pass 6/6: Deep Analysis - Reviewing business logic...", flush=True
)
findings = await self.run_review_pass(ReviewPass.DEEP_ANALYSIS, context)
print(f"[AI] Deep analysis complete: {len(findings)} findings", flush=True)
safe_print(f"[AI] Deep analysis complete: {len(findings)} findings")
return ("deep", findings)
# Always run security, quality, structural
@@ -408,22 +410,22 @@ class PRReviewEngine:
if has_ai_comments:
parallel_tasks.append(run_ai_triage_pass())
task_names.append("AI Triage")
print(
safe_print(
f"[AI] Found {len(context.ai_bot_comments)} AI comments to triage",
flush=True,
)
else:
print("[AI] Pass 5/6: Skipped (no AI comments to triage)", flush=True)
safe_print("[AI] Pass 5/6: Skipped (no AI comments to triage)")
# Only run deep analysis if needed
if needs_deep:
parallel_tasks.append(run_deep_pass())
task_names.append("Deep Analysis")
else:
print("[AI] Pass 6/6: Skipped (changes not complex enough)", flush=True)
safe_print("[AI] Pass 6/6: Skipped (changes not complex enough)")
# Run all passes in parallel
print(
safe_print(
f"[AI] Executing {len(parallel_tasks)} passes in parallel: {', '.join(task_names)}",
flush=True,
)
@@ -432,7 +434,7 @@ class PRReviewEngine:
# Collect results from all parallel passes
for i, result in enumerate(results):
if isinstance(result, Exception):
print(f"[AI] Pass '{task_names[i]}' failed: {result}", flush=True)
safe_print(f"[AI] Pass '{task_names[i]}' failed: {result}")
elif isinstance(result, tuple):
pass_type, data = result
if pass_type in ("security", "quality", "deep"):
@@ -450,12 +452,12 @@ class PRReviewEngine:
)
# Deduplicate findings
print(
safe_print(
f"[AI] Deduplicating {len(all_findings)} findings from all passes...",
flush=True,
)
unique_findings = self.deduplicate_findings(all_findings)
print(
safe_print(
f"[AI] Multi-pass review complete: {len(unique_findings)} findings, "
f"{len(structural_issues)} structural issues, {len(ai_triages)} AI triages",
flush=True,
@@ -509,7 +511,7 @@ class PRReviewEngine:
if block_type == "TextBlock" and hasattr(block, "text"):
result_text += block.text
except Exception as e:
print(f"[AI] Structural pass error: {e}", flush=True)
safe_print(f"[AI] Structural pass error: {e}")
return result_text
@@ -567,7 +569,7 @@ class PRReviewEngine:
if block_type == "TextBlock" and hasattr(block, "text"):
result_text += block.text
except Exception as e:
print(f"[AI] AI triage pass error: {e}", flush=True)
safe_print(f"[AI] AI triage pass error: {e}")
return result_text
@@ -21,6 +21,7 @@ try:
TriageCategory,
TriageResult,
)
from .io_utils import safe_print
except (ImportError, ValueError, SystemError):
from models import (
AICommentTriage,
@@ -32,6 +33,7 @@ except (ImportError, ValueError, SystemError):
TriageCategory,
TriageResult,
)
from services.io_utils import safe_print
# Evidence-based validation replaces confidence scoring
# Findings without evidence are filtered out instead of using confidence thresholds
@@ -57,10 +59,10 @@ class ResponseParser:
)
if json_match:
result = json.loads(json_match.group(1))
print(f"[AI] Quick scan result: {result}", flush=True)
safe_print(f"[AI] Quick scan result: {result}")
return result
except (json.JSONDecodeError, ValueError) as e:
print(f"[AI] Failed to parse scan result: {e}", flush=True)
safe_print(f"[AI] Failed to parse scan result: {e}")
return default_result
@@ -87,7 +89,7 @@ class ResponseParser:
# Apply evidence-based validation
if require_evidence and len(evidence.strip()) < MIN_EVIDENCE_LENGTH:
print(
safe_print(
f"[AI] Dropped finding '{f.get('title', 'unknown')}': "
f"insufficient evidence ({len(evidence.strip())} chars < {MIN_EVIDENCE_LENGTH})",
flush=True,
@@ -117,7 +119,7 @@ class ResponseParser:
)
)
except (json.JSONDecodeError, KeyError, ValueError) as e:
print(f"Failed to parse findings: {e}")
safe_print(f"Failed to parse findings: {e}")
return findings
@@ -147,7 +149,7 @@ class ResponseParser:
)
)
except (json.JSONDecodeError, KeyError, ValueError) as e:
print(f"Failed to parse structural issues: {e}")
safe_print(f"Failed to parse structural issues: {e}")
return issues
@@ -180,7 +182,7 @@ class ResponseParser:
)
)
except (json.JSONDecodeError, KeyError, ValueError) as e:
print(f"Failed to parse AI comment triages: {e}")
safe_print(f"Failed to parse AI comment triages: {e}")
return triages
@@ -218,6 +220,6 @@ class ResponseParser:
result.comment = data.get("comment")
except (json.JSONDecodeError, KeyError, ValueError) as e:
print(f"Failed to parse triage result: {e}")
safe_print(f"Failed to parse triage result: {e}")
return result
@@ -15,6 +15,11 @@ import os
from collections.abc import Callable
from typing import Any
try:
from .io_utils import safe_print
except (ImportError, ValueError, SystemError):
from core.io_utils import safe_print
logger = logging.getLogger(__name__)
# Check if debug mode is enabled
@@ -66,9 +71,9 @@ async def process_sdk_stream(
# Track subagent tool IDs to log their results
subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name
print(f"[{context_name}] Processing SDK stream...", flush=True)
safe_print(f"[{context_name}] Processing SDK stream...")
if DEBUG_MODE:
print(f"[DEBUG {context_name}] Awaiting response stream...", flush=True)
safe_print(f"[DEBUG {context_name}] Awaiting response stream...")
try:
async for msg in client.receive_response():
@@ -81,9 +86,8 @@ async def process_sdk_stream(
msg_details = ""
if hasattr(msg, "type"):
msg_details = f" (type={msg.type})"
print(
f"[DEBUG {context_name}] Message #{msg_count}: {msg_type}{msg_details}",
flush=True,
safe_print(
f"[DEBUG {context_name}] Message #{msg_count}: {msg_type}{msg_details}"
)
# Track thinking blocks
@@ -94,16 +98,14 @@ async def process_sdk_stream(
msg, "text", ""
)
if thinking_text:
print(
f"[{context_name}] AI thinking: {len(thinking_text)} chars",
flush=True,
safe_print(
f"[{context_name}] AI thinking: {len(thinking_text)} chars"
)
if DEBUG_MODE:
# Show first 200 chars of thinking
preview = thinking_text[:200].replace("\n", " ")
print(
f"[DEBUG {context_name}] Thinking preview: {preview}...",
flush=True,
safe_print(
f"[DEBUG {context_name}] Thinking preview: {preview}..."
)
# Invoke callback
if on_thinking:
@@ -118,9 +120,8 @@ async def process_sdk_stream(
tool_input = getattr(msg, "input", {})
if DEBUG_MODE:
print(
f"[DEBUG {context_name}] Tool call: {tool_name} (id={tool_id})",
flush=True,
safe_print(
f"[DEBUG {context_name}] Tool call: {tool_name} (id={tool_id})"
)
if tool_name == "Task":
@@ -129,9 +130,7 @@ async def process_sdk_stream(
agents_invoked.append(agent_name)
# Track this tool ID to log its result later
subagent_tool_ids[tool_id] = agent_name
print(
f"[{context_name}] Invoked agent: {agent_name}", flush=True
)
safe_print(f"[{context_name}] Invoked agent: {agent_name}")
elif tool_name == "StructuredOutput":
if tool_input:
# Warn if overwriting existing structured output
@@ -141,19 +140,13 @@ async def process_sdk_stream(
f"overwriting previous output"
)
structured_output = tool_input
print(
f"[{context_name}] Received structured output",
flush=True,
)
safe_print(f"[{context_name}] Received structured output")
# Invoke callback
if on_structured_output:
on_structured_output(tool_input)
elif DEBUG_MODE:
# Log other tool calls in debug mode
print(
f"[DEBUG {context_name}] Other tool: {tool_name}",
flush=True,
)
safe_print(f"[DEBUG {context_name}] Other tool: {tool_name}")
# Invoke callback for all tool uses
if on_tool_use:
@@ -180,15 +173,13 @@ async def process_sdk_stream(
result_preview = (
str(result_content)[:600].replace("\n", " ").strip()
)
print(
f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}",
flush=True,
safe_print(
f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}"
)
elif DEBUG_MODE:
status = "ERROR" if is_error else "OK"
print(
f"[DEBUG {context_name}] Tool result: {tool_id} [{status}]",
flush=True,
safe_print(
f"[DEBUG {context_name}] Tool result: {tool_id} [{status}]"
)
# Invoke callback
@@ -214,9 +205,8 @@ async def process_sdk_stream(
if agent_name not in agents_invoked:
agents_invoked.append(agent_name)
subagent_tool_ids[tool_id] = agent_name
print(
f"[{context_name}] Invoking agent: {agent_name}",
flush=True,
safe_print(
f"[{context_name}] Invoking agent: {agent_name}"
)
elif tool_name == "StructuredOutput":
if tool_input:
@@ -242,9 +232,8 @@ async def process_sdk_stream(
# Always print text content preview (not just in DEBUG_MODE)
text_preview = block.text[:500].replace("\n", " ").strip()
if text_preview:
print(
f"[{context_name}] AI response: {text_preview}{'...' if len(block.text) > 500 else ''}",
flush=True,
safe_print(
f"[{context_name}] AI response: {text_preview}{'...' if len(block.text) > 500 else ''}"
)
# Invoke callback
if on_text:
@@ -304,9 +293,8 @@ async def process_sdk_stream(
result_preview = (
str(result_content)[:600].replace("\n", " ").strip()
)
print(
f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}",
flush=True,
safe_print(
f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}"
)
# Invoke callback
@@ -319,25 +307,25 @@ async def process_sdk_stream(
f"[{context_name}] Error processing message #{msg_count}: {msg_error}"
)
if DEBUG_MODE:
print(
f"[DEBUG {context_name}] Message processing error: {msg_error}",
flush=True,
safe_print(
f"[DEBUG {context_name}] Message processing error: {msg_error}"
)
# Continue processing subsequent messages
except BrokenPipeError:
# Pipe closed by parent process - expected during shutdown
stream_error = "Output pipe closed"
logger.debug(f"[{context_name}] Output pipe closed by parent process")
except Exception as e:
# Log stream-level errors
stream_error = str(e)
logger.error(f"[{context_name}] SDK stream processing failed: {e}")
print(f"[{context_name}] ERROR: Stream processing failed: {e}", flush=True)
safe_print(f"[{context_name}] ERROR: Stream processing failed: {e}")
if DEBUG_MODE:
print(
f"[DEBUG {context_name}] Session ended. Total messages: {msg_count}",
flush=True,
)
safe_print(f"[DEBUG {context_name}] Session ended. Total messages: {msg_count}")
print(f"[{context_name}] Session ended. Total messages: {msg_count}", flush=True)
safe_print(f"[{context_name}] Session ended. Total messages: {msg_count}")
return {
"result_text": result_text,
+22 -12
View File
@@ -36,6 +36,17 @@ except ImportError:
)
from services import MRReviewEngine
# Import safe_print for BrokenPipeError handling
try:
from core.io_utils import safe_print
except ImportError:
# Fallback for direct script execution
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from core.io_utils import safe_print
@dataclass
class ProgressCallback:
@@ -121,7 +132,7 @@ class GitLabOrchestrator:
async def _gather_mr_context(self, mr_iid: int) -> MRContext:
"""Gather context for an MR."""
print(f"[GitLab] Fetching MR !{mr_iid} data...", flush=True)
safe_print(f"[GitLab] Fetching MR !{mr_iid} data...")
# Get MR details
mr_data = self.client.get_mr(mr_iid)
@@ -187,7 +198,7 @@ class GitLabOrchestrator:
Returns:
MRReviewResult with findings and overall assessment
"""
print(f"[GitLab] Starting review for MR !{mr_iid}", flush=True)
safe_print(f"[GitLab] Starting review for MR !{mr_iid}")
self._report_progress(
"gathering_context",
@@ -199,10 +210,9 @@ class GitLabOrchestrator:
try:
# Gather MR context
context = await self._gather_mr_context(mr_iid)
print(
safe_print(
f"[GitLab] Context gathered: {context.title} "
f"({len(context.changed_files)} files, {context.total_additions}+/{context.total_deletions}-)",
flush=True,
f"({len(context.changed_files)} files, {context.total_additions}+/{context.total_deletions}-)"
)
self._report_progress(
@@ -213,7 +223,7 @@ class GitLabOrchestrator:
findings, verdict, summary, blockers = await self.review_engine.run_review(
context
)
print(f"[GitLab] Review complete: {len(findings)} findings", flush=True)
safe_print(f"[GitLab] Review complete: {len(findings)} findings")
# Map verdict to overall_status
if verdict == MergeVerdict.BLOCKED:
@@ -264,7 +274,7 @@ class GitLabOrchestrator:
error_msg = f"MR !{mr_iid} not found in GitLab."
elif e.code == 429:
error_msg = "GitLab rate limit exceeded. Please try again later."
print(f"[GitLab] Review failed for !{mr_iid}: {error_msg}", flush=True)
safe_print(f"[GitLab] Review failed for !{mr_iid}: {error_msg}")
result = MRReviewResult(
mr_iid=mr_iid,
project=self.config.project,
@@ -276,7 +286,7 @@ class GitLabOrchestrator:
except json.JSONDecodeError as e:
error_msg = f"Invalid JSON response from GitLab: {e}"
print(f"[GitLab] Review failed for !{mr_iid}: {error_msg}", flush=True)
safe_print(f"[GitLab] Review failed for !{mr_iid}: {error_msg}")
result = MRReviewResult(
mr_iid=mr_iid,
project=self.config.project,
@@ -288,7 +298,7 @@ class GitLabOrchestrator:
except OSError as e:
error_msg = f"File system error: {e}"
print(f"[GitLab] Review failed for !{mr_iid}: {error_msg}", flush=True)
safe_print(f"[GitLab] Review failed for !{mr_iid}: {error_msg}")
result = MRReviewResult(
mr_iid=mr_iid,
project=self.config.project,
@@ -302,8 +312,8 @@ class GitLabOrchestrator:
# Catch-all for unexpected errors, with full traceback for debugging
error_details = f"{type(e).__name__}: {e}"
full_traceback = traceback.format_exc()
print(f"[GitLab] Review failed for !{mr_iid}: {error_details}", flush=True)
print(f"[GitLab] Traceback:\n{full_traceback}", flush=True)
safe_print(f"[GitLab] Review failed for !{mr_iid}: {error_details}")
safe_print(f"[GitLab] Traceback:\n{full_traceback}")
result = MRReviewResult(
mr_iid=mr_iid,
@@ -326,7 +336,7 @@ class GitLabOrchestrator:
Returns:
MRReviewResult with follow-up analysis
"""
print(f"[GitLab] Starting follow-up review for MR !{mr_iid}", flush=True)
safe_print(f"[GitLab] Starting follow-up review for MR !{mr_iid}")
# Load previous review
previous_review = MRReviewResult.load(self.gitlab_dir, mr_iid)
+18 -25
View File
@@ -38,6 +38,7 @@ if env_file.exists():
# Add gitlab runner directory to path for direct imports
sys.path.insert(0, str(Path(__file__).parent))
from core.io_utils import safe_print
from models import GitLabRunnerConfig
from orchestrator import GitLabOrchestrator, ProgressCallback
@@ -48,7 +49,7 @@ def print_progress(callback: ProgressCallback) -> None:
if callback.mr_iid:
prefix = f"[MR !{callback.mr_iid}] "
print(f"{prefix}[{callback.progress:3d}%] {callback.message}", flush=True)
safe_print(f"{prefix}[{callback.progress:3d}%] {callback.message}")
def get_config(args) -> GitLabRunnerConfig:
@@ -122,27 +123,24 @@ async def cmd_review_mr(args) -> int:
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
print(f"[DEBUG] Starting MR review for MR !{args.mr_iid}", flush=True)
print(f"[DEBUG] Project directory: {args.project_dir}", flush=True)
safe_print(f"[DEBUG] Starting MR review for MR !{args.mr_iid}")
safe_print(f"[DEBUG] Project directory: {args.project_dir}")
print("[DEBUG] Building config...", flush=True)
safe_print("[DEBUG] Building config...")
config = get_config(args)
print(
f"[DEBUG] Config built: project={config.project}, model={config.model}",
flush=True,
)
safe_print(f"[DEBUG] Config built: project={config.project}, model={config.model}")
print("[DEBUG] Creating orchestrator...", flush=True)
safe_print("[DEBUG] Creating orchestrator...")
orchestrator = GitLabOrchestrator(
project_dir=args.project_dir,
config=config,
progress_callback=print_progress,
)
print("[DEBUG] Orchestrator created", flush=True)
safe_print("[DEBUG] Orchestrator created")
print(f"[DEBUG] Calling orchestrator.review_mr({args.mr_iid})...", flush=True)
safe_print(f"[DEBUG] Calling orchestrator.review_mr({args.mr_iid})...")
result = await orchestrator.review_mr(args.mr_iid)
print(f"[DEBUG] review_mr returned, success={result.success}", flush=True)
safe_print(f"[DEBUG] review_mr returned, success={result.success}")
if result.success:
print(f"\n{'=' * 60}")
@@ -174,27 +172,22 @@ async def cmd_followup_review_mr(args) -> int:
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
print(f"[DEBUG] Starting follow-up review for MR !{args.mr_iid}", flush=True)
print(f"[DEBUG] Project directory: {args.project_dir}", flush=True)
safe_print(f"[DEBUG] Starting follow-up review for MR !{args.mr_iid}")
safe_print(f"[DEBUG] Project directory: {args.project_dir}")
print("[DEBUG] Building config...", flush=True)
safe_print("[DEBUG] Building config...")
config = get_config(args)
print(
f"[DEBUG] Config built: project={config.project}, model={config.model}",
flush=True,
)
safe_print(f"[DEBUG] Config built: project={config.project}, model={config.model}")
print("[DEBUG] Creating orchestrator...", flush=True)
safe_print("[DEBUG] Creating orchestrator...")
orchestrator = GitLabOrchestrator(
project_dir=args.project_dir,
config=config,
progress_callback=print_progress,
)
print("[DEBUG] Orchestrator created", flush=True)
safe_print("[DEBUG] Orchestrator created")
print(
f"[DEBUG] Calling orchestrator.followup_review_mr({args.mr_iid})...", flush=True
)
safe_print(f"[DEBUG] Calling orchestrator.followup_review_mr({args.mr_iid})...")
try:
result = await orchestrator.followup_review_mr(args.mr_iid)
@@ -202,7 +195,7 @@ async def cmd_followup_review_mr(args) -> int:
print(f"\nFollow-up review failed: {e}")
return 1
print(f"[DEBUG] followup_review_mr returned, success={result.success}", flush=True)
safe_print(f"[DEBUG] followup_review_mr returned, success={result.success}")
if result.success:
print(f"\n{'=' * 60}")
@@ -34,6 +34,17 @@ except ImportError:
ReviewSeverity,
)
# Import safe_print for BrokenPipeError handling
try:
from core.io_utils import safe_print
except ImportError:
# Fallback for direct script execution
import sys
from pathlib import Path as PathLib
sys.path.insert(0, str(PathLib(__file__).parent.parent.parent.parent))
from core.io_utils import safe_print
@dataclass
class ProgressCallback:
@@ -246,7 +257,7 @@ Provide your review in the following JSON format:
return self._parse_review_result(result_text)
except Exception as e:
print(f"[AI] Review error: {e}", flush=True)
safe_print(f"[AI] Review error: {e}")
raise RuntimeError(f"Review failed: {e}") from e
def _parse_review_result(
@@ -297,14 +308,11 @@ Provide your review in the following JSON format:
f"{finding.title} ({finding.file}:{finding.line})"
)
except (ValueError, KeyError) as e:
print(f"[AI] Skipping invalid finding: {e}", flush=True)
safe_print(f"[AI] Skipping invalid finding: {e}")
except json.JSONDecodeError as e:
print(f"[AI] Failed to parse JSON: {e}", flush=True)
print(
f"[AI] Raw response (first 500 chars): {result_text[:500]}",
flush=True,
)
safe_print(f"[AI] Failed to parse JSON: {e}")
safe_print(f"[AI] Raw response (first 500 chars): {result_text[:500]}")
summary = "Review completed but failed to parse structured output. Please re-run the review."
# Return with empty findings but keep verdict as READY_TO_MERGE
# since we couldn't determine if there are actual issues
+13
View File
@@ -93,6 +93,11 @@ if env_file.exists():
elif dev_env_file.exists():
load_dotenv(dev_env_file)
# Initialize Sentry early to capture any startup errors
from core.sentry import capture_exception, init_sentry
init_sentry(component="spec-runner")
from debug import debug, debug_error, debug_section, debug_success
from phase_config import resolve_model_id
from review import ReviewState
@@ -370,6 +375,14 @@ Examples:
f"To continue: python auto-claude/spec_runner.py --continue {orchestrator.spec_dir.name}"
)
sys.exit(1)
except Exception as e:
# Capture unexpected errors to Sentry
capture_exception(
e, spec_dir=str(orchestrator.spec_dir) if orchestrator else None
)
debug_error("spec_runner", f"Unexpected error: {e}")
print(f"\n\nUnexpected error: {e}")
sys.exit(1)
if __name__ == "__main__":
+8
View File
@@ -59,6 +59,7 @@ from .tool_input_validator import (
# Validators (for advanced usage)
from .validator import (
VALIDATORS,
validate_bash_command,
validate_chmod_command,
validate_dropdb_command,
validate_dropuser_command,
@@ -75,6 +76,9 @@ from .validator import (
validate_psql_command,
validate_redis_cli_command,
validate_rm_command,
validate_sh_command,
validate_shell_c_command,
validate_zsh_command,
)
__all__ = [
@@ -98,6 +102,10 @@ __all__ = [
"validate_git_command",
"validate_git_commit",
"validate_git_config",
"validate_shell_c_command",
"validate_bash_command",
"validate_sh_command",
"validate_zsh_command",
"validate_dropdb_command",
"validate_dropuser_command",
"validate_psql_command",
+142
View File
@@ -0,0 +1,142 @@
"""
Shell Interpreter Validators
=============================
Validators for shell interpreter commands (bash, sh, zsh) that execute
inline commands via the -c flag.
This closes a security bypass where `bash -c "npm test"` could execute
arbitrary commands since `bash` is in BASE_COMMANDS but the commands
inside -c were not being validated.
"""
import os
import shlex
from pathlib import Path
from project_analyzer import is_command_allowed
from .parser import extract_commands, split_command_segments
from .profile import get_security_profile
from .validation_models import ValidationResult
# Shell interpreters that can execute nested commands
SHELL_INTERPRETERS = {"bash", "sh", "zsh"}
def _extract_c_argument(command_string: str) -> str | None:
"""
Extract the command string from a shell -c invocation.
Handles various formats:
- bash -c 'command'
- bash -c "command"
- sh -c 'cmd1 && cmd2'
- zsh -c "complex command"
Args:
command_string: The full shell command (e.g., "bash -c 'npm test'")
Returns:
The command string after -c, or None if not a -c invocation
"""
try:
tokens = shlex.split(command_string)
except ValueError:
# Malformed command - let it fail safely
return None
if len(tokens) < 3:
return None
# Look for -c flag (standalone or combined with other flags like -xc, -ec, -ic)
for i, token in enumerate(tokens):
# Check for standalone -c or combined flags containing 'c'
# Combined flags: -xc, -ec, -ic, -exc, etc. (short options bundled together)
is_c_flag = token == "-c" or (
token.startswith("-") and not token.startswith("--") and "c" in token[1:]
)
if is_c_flag and i + 1 < len(tokens):
# The next token is the command to execute
return tokens[i + 1]
return None
def validate_shell_c_command(command_string: str) -> ValidationResult:
"""
Validate commands inside bash/sh/zsh -c '...' strings.
This prevents using shell interpreters to bypass the security allowlist.
All commands inside the -c string must also be allowed by the profile.
Args:
command_string: The full shell command (e.g., "bash -c 'npm test'")
Returns:
Tuple of (is_valid, error_message)
"""
# Extract the command after -c
inner_command = _extract_c_argument(command_string)
if inner_command is None:
# Not a -c invocation (e.g., "bash script.sh") - allow it
# The script itself would need to be in allowed commands
return True, ""
# Get the security profile for the current project
# Use PROJECT_DIR_ENV_VAR if set, otherwise use cwd
from .constants import PROJECT_DIR_ENV_VAR
project_dir = os.environ.get(PROJECT_DIR_ENV_VAR)
if not project_dir:
project_dir = os.getcwd()
try:
profile = get_security_profile(Path(project_dir))
except Exception:
# If we can't get the profile, fail safe by blocking
return False, "Could not load security profile to validate shell -c command"
# Extract command names for allowlist validation
inner_command_names = extract_commands(inner_command)
if not inner_command_names:
# Could not parse - be permissive for empty commands
# (e.g., bash -c "" is harmless)
if not inner_command.strip():
return True, ""
return False, f"Could not parse commands inside shell -c: {inner_command}"
# Validate each command name against the security profile
for cmd_name in inner_command_names:
is_allowed, reason = is_command_allowed(cmd_name, profile)
if not is_allowed:
return (
False,
f"Command '{cmd_name}' inside shell -c is not allowed: {reason}",
)
# Get full command segments for recursive shell validation
# (split_command_segments gives us full commands, not just names)
inner_segments = split_command_segments(inner_command)
for segment in inner_segments:
# Check if this segment is a shell invocation that needs recursive validation
segment_commands = extract_commands(segment)
if segment_commands:
first_cmd = segment_commands[0]
# Handle paths like /bin/bash
base_cmd = first_cmd.rsplit("/", 1)[-1] if "/" in first_cmd else first_cmd
if base_cmd in SHELL_INTERPRETERS:
valid, err = validate_shell_c_command(segment)
if not valid:
return False, f"Nested shell command not allowed: {err}"
return True, ""
# Alias for common shell interpreters - they all use the same validation
validate_bash_command = validate_shell_c_command
validate_sh_command = validate_shell_c_command
validate_zsh_command = validate_shell_c_command
+11
View File
@@ -43,6 +43,12 @@ from .process_validators import (
validate_killall_command,
validate_pkill_command,
)
from .shell_validators import (
validate_bash_command,
validate_sh_command,
validate_shell_c_command,
validate_zsh_command,
)
from .validation_models import ValidationResult, ValidatorFunction
from .validator_registry import VALIDATORS, get_validator
@@ -66,6 +72,11 @@ __all__ = [
"validate_git_commit",
"validate_git_command",
"validate_git_config",
# Shell validators
"validate_shell_c_command",
"validate_bash_command",
"validate_sh_command",
"validate_zsh_command",
# Database validators
"validate_dropdb_command",
"validate_dropuser_command",
@@ -25,6 +25,11 @@ from .process_validators import (
validate_killall_command,
validate_pkill_command,
)
from .shell_validators import (
validate_bash_command,
validate_sh_command,
validate_zsh_command,
)
from .validation_models import ValidatorFunction
# Map command names to their validation functions
@@ -39,6 +44,10 @@ VALIDATORS: dict[str, ValidatorFunction] = {
"init.sh": validate_init_script,
# Git
"git": validate_git_commit,
# Shell interpreters (validate commands inside -c)
"bash": validate_bash_command,
"sh": validate_sh_command,
"zsh": validate_zsh_command,
# Database - PostgreSQL
"dropdb": validate_dropdb_command,
"dropuser": validate_dropuser_command,
+26 -1
View File
@@ -2,8 +2,24 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
/**
* Sentry configuration embedded at build time.
*
* In CI builds, these come from GitHub secrets.
* In local development, these come from apps/frontend/.env (loaded by dotenv).
*
* The `define` option replaces these values at build time, so they're
* embedded in the bundle and available at runtime in packaged apps.
*/
const sentryDefines = {
'__SENTRY_DSN__': JSON.stringify(process.env.SENTRY_DSN || ''),
'__SENTRY_TRACES_SAMPLE_RATE__': JSON.stringify(process.env.SENTRY_TRACES_SAMPLE_RATE || '0.1'),
'__SENTRY_PROFILES_SAMPLE_RATE__': JSON.stringify(process.env.SENTRY_PROFILES_SAMPLE_RATE || '0.1'),
};
export default defineConfig({
main: {
define: sentryDefines,
plugins: [externalizeDepsPlugin({
// Bundle these packages into the main process (they won't be in node_modules in packaged app)
exclude: [
@@ -11,7 +27,15 @@ export default defineConfig({
'chokidar',
'kuzu',
'electron-updater',
'@electron-toolkit/utils'
'@electron-toolkit/utils',
// Sentry and its transitive dependencies (opentelemetry -> debug -> ms)
'@sentry/electron',
'@sentry/core',
'@sentry/node',
'@sentry/utils',
'@opentelemetry/instrumentation',
'debug',
'ms'
]
})],
build: {
@@ -35,6 +59,7 @@ export default defineConfig({
}
},
renderer: {
define: sentryDefines,
root: resolve(__dirname, 'src/renderer'),
build: {
rollupOptions: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude-ui",
"version": "2.7.3",
"version": "2.7.4",
"type": "module",
"description": "Desktop UI for Auto Claude autonomous coding framework",
"homepage": "https://github.com/AndyMik90/Auto-Claude",
@@ -148,7 +148,8 @@ describe('IPC Bridge Integration', () => {
const submitReview = electronAPI['submitReview'] as (
id: string,
approved: boolean,
feedback?: string
feedback?: string,
images?: unknown[]
) => Promise<unknown>;
await submitReview('task-id', false, 'Needs more work');
@@ -156,7 +157,8 @@ describe('IPC Bridge Integration', () => {
'task:review',
'task-id',
false,
'Needs more work'
'Needs more work',
undefined
);
});
});
@@ -39,11 +39,14 @@ vi.mock('child_process', async (importOriginal) => {
});
// Mock claude-profile-manager to bypass auth checks in tests
const mockProfileManager = {
hasValidAuth: () => true,
getActiveProfile: () => ({ profileId: 'default', profileName: 'Default' })
};
vi.mock('../../main/claude-profile-manager', () => ({
getClaudeProfileManager: () => ({
hasValidAuth: () => true,
getActiveProfile: () => ({ profileId: 'default', profileName: 'Default' })
})
getClaudeProfileManager: () => mockProfileManager,
initializeClaudeProfileManager: () => Promise.resolve(mockProfileManager)
}));
// Mock validatePythonPath to allow test paths (security validation is tested separately)
@@ -17,10 +17,27 @@ import {
} from '../cli-tool-manager';
import {
findWindowsExecutableViaWhere,
findWindowsExecutableViaWhereAsync
findWindowsExecutableViaWhereAsync,
isSecurePath
} from '../utils/windows-paths';
import { findExecutable, findExecutableAsync } from '../env-utils';
type SpawnOptions = Parameters<(typeof import('../env-utils'))['getSpawnOptions']>[1];
type MockDirent = import('fs').Dirent<import('node:buffer').NonSharedBuffer>;
const createDirent = (name: string, isDir: boolean): MockDirent =>
({
name,
parentPath: '',
isDirectory: () => isDir,
isFile: () => !isDir,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false
}) as unknown as MockDirent;
// Mock Electron app
vi.mock('electron', () => ({
app: {
@@ -52,7 +69,7 @@ vi.mock('child_process', () => {
// so when tests call vi.mocked(execFileSync).mockReturnValue(), it affects execSync too
const sharedSyncMock = vi.fn();
const mockExecFile = vi.fn((cmd: any, args: any, options: any, callback: any) => {
const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callback: unknown) => {
// Return a minimal ChildProcess-like object
const childProcess = {
stdout: { on: vi.fn() },
@@ -62,13 +79,14 @@ vi.mock('child_process', () => {
// If callback is provided, call it asynchronously
if (typeof callback === 'function') {
setImmediate(() => callback(null, 'claude-code version 1.0.0\n', ''));
const cb = callback as (error: Error | null, stdout: string, stderr: string) => void;
setImmediate(() => cb(null, 'claude-code version 1.0.0\n', ''));
}
return childProcess as any;
return childProcess as unknown as import('child_process').ChildProcess;
});
const mockExec = vi.fn((cmd: any, options: any, callback: any) => {
const mockExec = vi.fn((cmd: unknown, options: unknown, callback: unknown) => {
// Return a minimal ChildProcess-like object
const childProcess = {
stdout: { on: vi.fn() },
@@ -78,10 +96,11 @@ vi.mock('child_process', () => {
// If callback is provided, call it asynchronously
if (typeof callback === 'function') {
setImmediate(() => callback(null, 'claude-code version 1.0.0\n', ''));
const cb = callback as (error: Error | null, stdout: string, stderr: string) => void;
setImmediate(() => cb(null, 'claude-code version 1.0.0\n', ''));
}
return childProcess as any;
return childProcess as unknown as import('child_process').ChildProcess;
});
return {
@@ -93,18 +112,23 @@ vi.mock('child_process', () => {
});
// Mock env-utils to avoid PATH augmentation complexity
vi.mock('../env-utils', () => ({
vi.mock('../env-utils', () => {
const mockShouldUseShell = vi.fn((command: string) => {
if (process.platform !== 'win32') {
return false;
}
const trimmed = command.trim();
const unquoted =
trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;
return /\.(cmd|bat)$/i.test(unquoted);
});
return ({
findExecutable: vi.fn(() => null), // Return null to force platform-specific path checking
findExecutableAsync: vi.fn(() => Promise.resolve(null)),
getAugmentedEnv: vi.fn(() => ({ PATH: '' })),
getAugmentedEnvAsync: vi.fn(() => Promise.resolve({ PATH: '' })),
shouldUseShell: vi.fn((command: string) => {
// Mock shouldUseShell to match actual behavior
if (process.platform !== 'win32') {
return false;
}
return /\.(cmd|bat)$/i.test(command);
}),
shouldUseShell: mockShouldUseShell,
getSpawnCommand: vi.fn((command: string) => {
// Mock getSpawnCommand to match actual behavior
const trimmed = command.trim();
@@ -122,12 +146,13 @@ vi.mock('../env-utils', () => ({
}
return trimmed;
}),
getSpawnOptions: vi.fn((command: string, baseOptions?: any) => ({
getSpawnOptions: vi.fn((command: string, baseOptions?: SpawnOptions) => ({
...baseOptions,
shell: /\.(cmd|bat)$/i.test(command) && process.platform === 'win32'
shell: mockShouldUseShell(command)
})),
existsAsync: vi.fn(() => Promise.resolve(false))
}));
});
});
// Mock homebrew-python utility
vi.mock('../utils/homebrew-python', () => ({
@@ -137,7 +162,11 @@ vi.mock('../utils/homebrew-python', () => ({
// Mock windows-paths utility
vi.mock('../utils/windows-paths', () => ({
findWindowsExecutableViaWhere: vi.fn(() => null),
findWindowsExecutableViaWhereAsync: vi.fn(() => Promise.resolve(null))
findWindowsExecutableViaWhereAsync: vi.fn(() => Promise.resolve(null)),
isSecurePath: vi.fn(() => true),
getWindowsExecutablePaths: vi.fn(() => []),
getWindowsExecutablePathsAsync: vi.fn(() => Promise.resolve([])),
WINDOWS_GIT_PATHS: {}
}));
describe('cli-tool-manager - Claude CLI NVM detection', () => {
@@ -176,12 +205,12 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
});
// Mock Node.js version directories (three versions)
const mockDirents = [
{ name: 'v20.0.0', isDirectory: () => true },
{ name: 'v22.17.0', isDirectory: () => true },
{ name: 'v18.20.0', isDirectory: () => true },
const mockDirents: MockDirent[] = [
createDirent('v20.0.0', true),
createDirent('v22.17.0', true),
createDirent('v18.20.0', true),
];
vi.mocked(readdirSync).mockReturnValue(mockDirents as any);
vi.mocked(readdirSync).mockReturnValue(mockDirents);
// Mock execFileSync to simulate successful version check
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
@@ -245,11 +274,11 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
return false;
});
const mockDirents = [
{ name: 'v22.17.0', isDirectory: () => true },
{ name: 'v20.0.0', isDirectory: () => true },
const mockDirents: MockDirent[] = [
createDirent('v22.17.0', true),
createDirent('v20.0.0', true),
];
vi.mocked(readdirSync).mockReturnValue(mockDirents as any);
vi.mocked(readdirSync).mockReturnValue(mockDirents);
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.5.0\n');
const result = getToolInfo('claude');
@@ -272,10 +301,10 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
return false;
});
const mockDirents = [
{ name: 'v22.17.0', isDirectory: () => true },
const mockDirents: MockDirent[] = [
createDirent('v22.17.0', true),
];
vi.mocked(readdirSync).mockReturnValue(mockDirents as any);
vi.mocked(readdirSync).mockReturnValue(mockDirents);
// Mock validation failure
vi.mocked(execFileSync).mockImplementation(() => {
@@ -301,12 +330,12 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
});
// Versions in random order
const mockDirents = [
{ name: 'v18.20.0', isDirectory: () => true },
{ name: 'v22.17.0', isDirectory: () => true },
{ name: 'v20.5.0', isDirectory: () => true },
const mockDirents: MockDirent[] = [
createDirent('v18.20.0', true),
createDirent('v22.17.0', true),
createDirent('v20.5.0', true),
];
vi.mocked(readdirSync).mockReturnValue(mockDirents as any);
vi.mocked(readdirSync).mockReturnValue(mockDirents);
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
const result = getToolInfo('claude');
@@ -345,6 +374,35 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
expect(result.source).toBe('system-path');
});
it('should ignore insecure Windows Claude CLI path from where.exe', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true
});
vi.mocked(os.homedir).mockReturnValue('C:\\Users\\test');
vi.mocked(findExecutable).mockReturnValue(null);
vi.mocked(findWindowsExecutableViaWhere).mockReturnValue(
'D:\\Tools\\claude.cmd'
);
vi.mocked(isSecurePath).mockReturnValueOnce(false);
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('Tools') && pathStr.includes('claude.cmd')) {
return true;
}
return false;
});
const result = getToolInfo('claude');
expect(result.found).toBe(false);
expect(result.source).toBe('fallback');
expect(execFileSync).not.toHaveBeenCalled();
expect(isSecurePath).toHaveBeenCalledWith('D:\\Tools\\claude.cmd');
});
it('should detect Claude CLI in Unix .local/bin path', () => {
vi.mocked(os.homedir).mockReturnValue('/home/user');
@@ -59,7 +59,9 @@ describe('InsightsConfig', () => {
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token');
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.z.ai');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('key');
expect(env.PYTHONPATH).toBe(['/site-packages', '/backend'].join(path.delimiter));
expect(env.PYTHONPATH).toBe(
[path.resolve('/site-packages'), path.resolve('/backend')].join(path.delimiter)
);
});
it('should clear ANTHROPIC env vars in OAuth mode when no API profile is set', async () => {
@@ -84,7 +86,7 @@ describe('InsightsConfig', () => {
const env = await config.getProcessEnv();
expect(env.PYTHONPATH).toBe('/backend');
expect(env.PYTHONPATH).toBe(path.resolve('/backend'));
});
it('should keep PYTHONPATH from python env when auto-build path is missing', async () => {
@@ -94,6 +96,6 @@ describe('InsightsConfig', () => {
const env = await config.getProcessEnv();
expect(env.PYTHONPATH).toBe('/site-packages');
expect(env.PYTHONPATH).toBe(path.resolve('/site-packages'));
});
});
+19 -3
View File
@@ -5,7 +5,7 @@ import { AgentState } from './agent-state';
import { AgentEvents } from './agent-events';
import { AgentProcessManager } from './agent-process';
import { AgentQueueManager } from './agent-queue';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
import {
SpecCreationMetadata,
TaskExecutionOptions,
@@ -96,7 +96,15 @@ export class AgentManager extends EventEmitter {
baseBranch?: string
): Promise<void> {
// Pre-flight auth check: Verify active profile has valid authentication
const profileManager = getClaudeProfileManager();
// Ensure profile manager is initialized to prevent race condition
let profileManager;
try {
profileManager = await initializeClaudeProfileManager();
} catch (error) {
console.error('[AgentManager] Failed to initialize profile manager:', error);
this.emit('error', taskId, 'Failed to initialize profile manager. Please check file permissions and disk space.');
return;
}
if (!profileManager.hasValidAuth()) {
this.emit('error', taskId, 'Claude authentication required. Please authenticate in Settings > Claude Profiles before starting tasks.');
return;
@@ -174,7 +182,15 @@ export class AgentManager extends EventEmitter {
options: TaskExecutionOptions = {}
): Promise<void> {
// Pre-flight auth check: Verify active profile has valid authentication
const profileManager = getClaudeProfileManager();
// Ensure profile manager is initialized to prevent race condition
let profileManager;
try {
profileManager = await initializeClaudeProfileManager();
} catch (error) {
console.error('[AgentManager] Failed to initialize profile manager:', error);
this.emit('error', taskId, 'Failed to initialize profile manager. Please check file permissions and disk space.');
return;
}
if (!profileManager.hasValidAuth()) {
this.emit('error', taskId, 'Claude authentication required. Please authenticate in Settings > Claude Profiles before starting tasks.');
return;
@@ -1,7 +1,12 @@
import { spawn } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import { existsSync, readFileSync } from 'fs';
import { app } from 'electron';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { EventEmitter } from 'events';
import { AgentState } from './agent-state';
import { AgentEvents } from './agent-events';
@@ -457,7 +462,7 @@ export class AgentProcessManager {
let sequenceNumber = 0;
// FIX (ACS-203): Track completed phases to prevent phase overlaps
// When a phase completes, it's added to this array before transitioning to the next phase
let completedPhases: CompletablePhase[] = [];
const completedPhases: CompletablePhase[] = [];
this.emitter.emit('execution-progress', taskId, {
phase: currentPhase,
+27 -1
View File
@@ -47,6 +47,9 @@ type UpdateChannel = 'latest' | 'beta';
*/
export function setUpdateChannel(channel: UpdateChannel): void {
autoUpdater.channel = channel;
// Clear any downloaded update info when channel changes to prevent showing
// an Install button for an update from a different channel
downloadedUpdateInfo = null;
console.warn(`[app-updater] Update channel set to: ${channel}`);
}
@@ -62,6 +65,9 @@ if (DEBUG_UPDATER) {
let mainWindow: BrowserWindow | null = null;
// Track downloaded update state so it persists across Settings page navigations
let downloadedUpdateInfo: AppUpdateInfo | null = null;
/**
* Initialize the app updater system
*
@@ -107,6 +113,13 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false)
// Update downloaded - ready to install
autoUpdater.on('update-downloaded', (info) => {
console.warn('[app-updater] Update downloaded:', info.version);
// Store downloaded update info so it persists across Settings page navigations
// releaseNotes can be string | ReleaseNoteInfo[] | null | undefined, only use if string
downloadedUpdateInfo = {
version: info.version,
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
releaseDate: info.releaseDate
};
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_DOWNLOADED, {
version: info.version,
@@ -215,9 +228,10 @@ export async function checkForUpdates(): Promise<AppUpdateInfo | null> {
return null;
}
// releaseNotes can be string | ReleaseNoteInfo[] | null | undefined, only use if string
return {
version: result.updateInfo.version,
releaseNotes: result.updateInfo.releaseNotes as string | undefined,
releaseNotes: typeof result.updateInfo.releaseNotes === 'string' ? result.updateInfo.releaseNotes : undefined,
releaseDate: result.updateInfo.releaseDate
};
} catch (error) {
@@ -256,6 +270,15 @@ export function getCurrentVersion(): string {
return autoUpdater.currentVersion.version;
}
/**
* Get downloaded update info if an update has been downloaded and is ready to install.
* This allows the UI to show "Install and Restart" even if the user opens Settings
* after the download completed in the background.
*/
export function getDownloadedUpdateInfo(): AppUpdateInfo | null {
return downloadedUpdateInfo;
}
/**
* Check if a version string represents a prerelease (beta, alpha, rc, etc.)
*/
@@ -422,6 +445,9 @@ export async function setUpdateChannelWithDowngradeCheck(
triggerDowngradeCheck = false
): Promise<AppUpdateInfo | null> {
autoUpdater.channel = channel;
// Clear any downloaded update info when channel changes to prevent showing
// an Install button for an update from a different channel
downloadedUpdateInfo = null;
console.warn(`[app-updater] Update channel set to: ${channel}`);
// If switching to stable and downgrade check requested, look for stable version
@@ -1,7 +1,12 @@
import { EventEmitter } from 'events';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { app } from 'electron';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { AUTO_BUILD_PATHS, DEFAULT_CHANGELOG_PATH } from '../../shared/constants';
import { getToolPath } from '../cli-tool-manager';
import type {
@@ -568,6 +568,7 @@ export function getClaudeProfileManager(): ClaudeProfileManager {
* Initialize and get the singleton Claude profile manager instance (async)
* This ensures the profile manager is fully initialized before use.
* Uses promise caching to prevent concurrent initialization.
* The cached promise is reset on failure to allow retries after transient errors.
*/
export async function initializeClaudeProfileManager(): Promise<ClaudeProfileManager> {
if (!profileManager) {
@@ -581,9 +582,16 @@ export async function initializeClaudeProfileManager(): Promise<ClaudeProfileMan
// If initialization is in progress, wait for it (promise caching)
if (!initPromise) {
initPromise = profileManager.initialize().then(() => {
return profileManager!;
});
initPromise = profileManager.initialize()
.then(() => {
return profileManager!;
})
.catch((error) => {
// Reset cached promise on failure so retries can succeed
// This allows recovery from transient errors (e.g., disk full, permission issues)
initPromise = null;
throw error;
});
}
return initPromise;
@@ -35,12 +35,19 @@ export function getBestAvailableProfile(
// 2. Lower weekly usage (more important than session)
// 3. Lower session usage
// 4. More recently authenticated
const isDebug = process.env.DEBUG === 'true';
if (isDebug) {
console.warn('[ProfileScorer] Evaluating', candidates.length, 'candidate profiles (excluding:', excludeProfileId, ')');
}
const scoredProfiles: ScoredProfile[] = candidates.map(profile => {
let score = 100; // Base score
if (isDebug) console.warn('[ProfileScorer] Scoring profile:', profile.name, '(', profile.id, ')');
// Check rate limit status
const rateLimitStatus = isProfileRateLimited(profile);
if (isDebug) console.warn('[ProfileScorer] Rate limit status:', rateLimitStatus);
if (rateLimitStatus.limited) {
// Severely penalize rate-limited profiles
if (rateLimitStatus.type === 'weekly') {
@@ -73,10 +80,14 @@ export function getBestAvailableProfile(
}
// Check if authenticated
if (!isProfileAuthenticated(profile)) {
const isAuth = isProfileAuthenticated(profile);
if (isDebug) console.warn('[ProfileScorer] isProfileAuthenticated:', isAuth, 'hasOAuthToken:', !!profile.oauthToken, 'hasConfigDir:', !!profile.configDir);
if (!isAuth) {
score -= 500; // Severely penalize unauthenticated profiles
if (isDebug) console.warn('[ProfileScorer] Applied -500 penalty for no auth');
}
if (isDebug) console.warn('[ProfileScorer] Final score:', score);
return { profile, score };
});
@@ -56,9 +56,16 @@ export async function createProfileDirectory(profileName: string): Promise<strin
/**
* Check if a profile has valid authentication
* (checks if the config directory has credential files or OAuth account info)
* (checks for OAuth token or config directory credential files)
*/
export function isProfileAuthenticated(profile: ClaudeProfile): boolean {
// Check for direct OAuth token first (OAuth-only profiles without configDir)
// This enables auto-switch to work with profiles that only have oauthToken set
if (hasValidToken(profile)) {
return true;
}
// Check for configDir-based credentials (legacy or CLI-authenticated profiles)
const configDir = profile.configDir;
if (!configDir || !existsSync(configDir)) {
return false;
@@ -134,7 +141,6 @@ export function hasValidToken(profile: ClaudeProfile): boolean {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
if (new Date(profile.tokenCreatedAt) < oneYearAgo) {
console.warn('[ProfileUtils] Token expired for profile:', profile.name);
return false;
}
}
@@ -19,6 +19,13 @@ export class UsageMonitor extends EventEmitter {
private currentUsage: ClaudeUsageSnapshot | null = null;
private isChecking = false;
private useApiMethod = true; // Try API first, fall back to CLI if it fails
// Swap loop protection: track profiles that recently failed auth
private authFailedProfiles: Map<string, number> = new Map(); // profileId -> timestamp
private static AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown
// Debug flag for verbose logging
private readonly isDebug = process.env.DEBUG === 'true';
private constructor() {
super();
@@ -40,7 +47,7 @@ export class UsageMonitor extends EventEmitter {
const settings = profileManager.getAutoSwitchSettings();
if (!settings.enabled || !settings.proactiveSwapEnabled) {
console.warn('[UsageMonitor] Proactive monitoring disabled');
console.warn('[UsageMonitor] Proactive monitoring disabled. Settings:', JSON.stringify(settings, null, 2));
return;
}
@@ -118,6 +125,15 @@ export class UsageMonitor extends EventEmitter {
const weeklyExceeded = usage.weeklyPercent >= settings.weeklyThreshold;
if (sessionExceeded || weeklyExceeded) {
if (this.isDebug) {
console.warn('[UsageMonitor:TRACE] Threshold exceeded', {
sessionPercent: usage.sessionPercent,
weekPercent: usage.weeklyPercent,
activeProfile: activeProfile.id,
hasToken: !!decryptedToken
});
}
console.warn('[UsageMonitor] Threshold exceeded:', {
sessionPercent: usage.sessionPercent,
sessionThreshold: settings.sessionThreshold,
@@ -130,8 +146,48 @@ export class UsageMonitor extends EventEmitter {
activeProfile.id,
sessionExceeded ? 'session' : 'weekly'
);
} else {
if (this.isDebug) {
console.warn('[UsageMonitor:TRACE] Usage OK', {
sessionPercent: usage.sessionPercent,
weekPercent: usage.weeklyPercent
});
}
}
} catch (error) {
// Check for auth failure (401/403) from fetchUsageViaAPI
if ((error as any).statusCode === 401 || (error as any).statusCode === 403) {
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager.getActiveProfile();
if (activeProfile) {
// Mark this profile as auth-failed to prevent swap loops
this.authFailedProfiles.set(activeProfile.id, Date.now());
console.warn('[UsageMonitor] Auth failure detected, marked profile as failed:', activeProfile.id);
// Clean up expired entries from the failed profiles map
const now = Date.now();
this.authFailedProfiles.forEach((timestamp, profileId) => {
if (now - timestamp > UsageMonitor.AUTH_FAILURE_COOLDOWN_MS) {
this.authFailedProfiles.delete(profileId);
}
});
try {
const excludeProfiles = Array.from(this.authFailedProfiles.keys());
console.warn('[UsageMonitor] Attempting proactive swap (excluding failed profiles):', excludeProfiles);
await this.performProactiveSwap(
activeProfile.id,
'session', // Treat auth failure as session limit for immediate swap
excludeProfiles
);
return;
} catch (swapError) {
console.error('[UsageMonitor] Failed to perform auth-failure swap:', swapError);
}
}
}
console.error('[UsageMonitor] Check failed:', error);
} finally {
this.isChecking = false;
@@ -190,6 +246,12 @@ export class UsageMonitor extends EventEmitter {
if (!response.ok) {
console.error('[UsageMonitor] API error:', response.status, response.statusText);
// Throw specific error for auth failures so we can trigger a swap
if (response.status === 401 || response.status === 403) {
const error = new Error(`API Auth Failure: ${response.status}`);
(error as any).statusCode = response.status;
throw error;
}
return null;
}
@@ -220,7 +282,12 @@ export class UsageMonitor extends EventEmitter {
? 'weekly'
: 'session'
};
} catch (error) {
} catch (error: any) {
// Re-throw auth failures to be handled by checkUsageAndSwap
if (error?.statusCode === 401 || error?.statusCode === 403) {
throw error;
}
console.error('[UsageMonitor] API fetch failed:', error);
return null;
}
@@ -270,22 +337,34 @@ export class UsageMonitor extends EventEmitter {
/**
* Perform proactive profile swap
* @param currentProfileId - The profile to switch from
* @param limitType - The type of limit that triggered the swap
* @param additionalExclusions - Additional profile IDs to exclude (e.g., auth-failed profiles)
*/
private async performProactiveSwap(
currentProfileId: string,
limitType: 'session' | 'weekly'
limitType: 'session' | 'weekly',
additionalExclusions: string[] = []
): Promise<void> {
const profileManager = getClaudeProfileManager();
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
if (!bestProfile) {
console.warn('[UsageMonitor] No alternative profile for proactive swap');
// Get all profiles to swap to, excluding current and any additional exclusions
const allProfiles = profileManager.getProfilesSortedByAvailability();
const excludeIds = new Set([currentProfileId, ...additionalExclusions]);
const eligibleProfiles = allProfiles.filter(p => !excludeIds.has(p.id));
if (eligibleProfiles.length === 0) {
console.warn('[UsageMonitor] No alternative profile for proactive swap (excluded:', Array.from(excludeIds), ')');
this.emit('proactive-swap-failed', {
reason: 'no_alternative',
currentProfile: currentProfileId
reason: additionalExclusions.length > 0 ? 'all_alternatives_failed_auth' : 'no_alternative',
currentProfile: currentProfileId,
excludedProfiles: Array.from(excludeIds)
});
return;
}
// Use the best available from eligible profiles
const bestProfile = eligibleProfiles[0];
console.warn('[UsageMonitor] Proactive swap:', {
from: currentProfileId,
+86 -25
View File
@@ -20,7 +20,7 @@
* - Graceful fallbacks when tools not found
*/
import { execFileSync, execFile, execSync, exec } from 'child_process';
import { execFileSync, execFile } from 'child_process';
import { existsSync, readdirSync, promises as fsPromises } from 'fs';
import path from 'path';
import os from 'os';
@@ -28,10 +28,19 @@ import { promisify } from 'util';
import { app } from 'electron';
import { findExecutable, findExecutableAsync, getAugmentedEnv, getAugmentedEnvAsync, shouldUseShell, existsAsync } from './env-utils';
import type { ToolDetectionResult } from '../shared/types';
import { findHomebrewPython as findHomebrewPythonUtil } from './utils/homebrew-python';
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
import { findHomebrewPython as findHomebrewPythonUtil } from './utils/homebrew-python';
type ExecFileSyncOptionsWithVerbatim = import('child_process').ExecFileSyncOptions & {
windowsVerbatimArguments?: boolean;
};
type ExecFileAsyncOptionsWithVerbatim = import('child_process').ExecFileOptionsWithStringEncoding & {
windowsVerbatimArguments?: boolean;
};
const normalizeExecOutput = (output: string | Buffer): string =>
typeof output === 'string' ? output : output.toString('utf-8');
import {
getWindowsExecutablePaths,
getWindowsExecutablePathsAsync,
@@ -138,6 +147,16 @@ interface ClaudeDetectionPaths {
* This pure function consolidates path configuration used by both sync
* and async detection methods.
*
* IMPORTANT: This function has a corresponding implementation in the Python backend:
* apps/backend/core/client.py (_get_claude_detection_paths)
*
* Both implementations MUST be kept in sync to ensure consistent detection behavior
* across the Electron frontend and Python backend.
*
* When adding new detection paths, update BOTH:
* 1. This function (getClaudeDetectionPaths in cli-tool-manager.ts)
* 2. _get_claude_detection_paths() in client.py
*
* @param homeDir - User's home directory (from os.homedir())
* @returns Object containing homebrew, platform, and NVM paths
*
@@ -916,28 +935,51 @@ class CLIToolManager {
*/
private validateClaude(claudeCmd: string): ToolValidation {
try {
const needsShell = shouldUseShell(claudeCmd);
const trimmedCmd = claudeCmd.trim();
const unquotedCmd =
trimmedCmd.startsWith('"') && trimmedCmd.endsWith('"')
? trimmedCmd.slice(1, -1)
: trimmedCmd;
const needsShell = shouldUseShell(trimmedCmd);
const cmdDir = path.dirname(unquotedCmd);
const env = getAugmentedEnv(cmdDir && cmdDir !== '.' ? [cmdDir] : []);
let version: string;
if (needsShell) {
// For .cmd/.bat files on Windows, use cmd.exe with argument array
// This avoids shell command injection while handling spaces in paths
version = execFileSync('cmd.exe', ['/c', claudeCmd, '--version'], {
// For .cmd/.bat files on Windows, use cmd.exe with a quoted command line
// /s preserves quotes so paths with spaces are handled correctly.
if (!isSecurePath(unquotedCmd)) {
return {
valid: false,
message: `Claude CLI path failed security validation: ${unquotedCmd}`,
};
}
const cmdExe = process.env.ComSpec
|| path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe');
const cmdLine = `""${unquotedCmd}" --version"`;
const execOptions: ExecFileSyncOptionsWithVerbatim = {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env: getAugmentedEnv(),
}).trim();
windowsVerbatimArguments: true,
env,
};
version = normalizeExecOutput(
execFileSync(cmdExe, ['/d', '/s', '/c', cmdLine], execOptions)
).trim();
} else {
// For .exe files and non-Windows, use execFileSync
version = execFileSync(claudeCmd, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
shell: false,
env: getAugmentedEnv(),
}).trim();
version = normalizeExecOutput(
execFileSync(unquotedCmd, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
shell: false,
env,
})
).trim();
}
// Claude CLI version output format: "claude-code version X.Y.Z" or similar
@@ -1032,33 +1074,52 @@ class CLIToolManager {
*/
private async validateClaudeAsync(claudeCmd: string): Promise<ToolValidation> {
try {
const needsShell = shouldUseShell(claudeCmd);
const trimmedCmd = claudeCmd.trim();
const unquotedCmd =
trimmedCmd.startsWith('"') && trimmedCmd.endsWith('"')
? trimmedCmd.slice(1, -1)
: trimmedCmd;
const needsShell = shouldUseShell(trimmedCmd);
const cmdDir = path.dirname(unquotedCmd);
const env = await getAugmentedEnvAsync(cmdDir && cmdDir !== '.' ? [cmdDir] : []);
let stdout: string;
if (needsShell) {
// For .cmd/.bat files on Windows, use cmd.exe with argument array
// This avoids shell command injection while handling spaces in paths
const result = await execFileAsync('cmd.exe', ['/c', claudeCmd, '--version'], {
// For .cmd/.bat files on Windows, use cmd.exe with a quoted command line
// /s preserves quotes so paths with spaces are handled correctly.
if (!isSecurePath(unquotedCmd)) {
return {
valid: false,
message: `Claude CLI path failed security validation: ${unquotedCmd}`,
};
}
const cmdExe = process.env.ComSpec
|| path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe');
const cmdLine = `""${unquotedCmd}" --version"`;
const execOptions: ExecFileAsyncOptionsWithVerbatim = {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env: await getAugmentedEnvAsync(),
});
windowsVerbatimArguments: true,
env,
};
const result = await execFileAsync(cmdExe, ['/d', '/s', '/c', cmdLine], execOptions);
stdout = result.stdout;
} else {
// For .exe files and non-Windows, use execFileAsync
const result = await execFileAsync(claudeCmd, ['--version'], {
const result = await execFileAsync(unquotedCmd, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
shell: false,
env: await getAugmentedEnvAsync(),
env,
});
stdout = result.stdout;
}
const version = stdout.trim();
const version = normalizeExecOutput(stdout).trim();
const match = version.match(/(\d+\.\d+\.\d+)/);
const versionStr = match ? match[1] : version.split('\n')[0];
+11
View File
@@ -15,6 +15,7 @@ import * as fs from 'fs';
import { promises as fsPromises } from 'fs';
import { execFileSync, execFile } from 'child_process';
import { promisify } from 'util';
import { getSentryEnvForSubprocess } from './sentry';
const execFileAsync = promisify(execFile);
@@ -237,6 +238,11 @@ export function getAugmentedEnv(additionalPaths?: string[]): Record<string, stri
// Prepend new paths to PATH (prepend so they take priority)
env.PATH = [...pathsToAdd, currentPath].filter(Boolean).join(pathSeparator);
// Add Sentry environment variables for Python subprocesses
// These are embedded at build time and need to be passed explicitly
const sentryEnv = getSentryEnvForSubprocess();
Object.assign(env, sentryEnv);
return env;
}
@@ -397,6 +403,11 @@ export async function getAugmentedEnvAsync(additionalPaths?: string[]): Promise<
// Prepend new paths to PATH (prepend so they take priority)
env.PATH = [...pathsToAdd, currentPath].filter(Boolean).join(pathSeparator);
// Add Sentry environment variables for Python subprocesses
// These are embedded at build time and need to be passed explicitly
const sentryEnv = getSentryEnvForSubprocess();
Object.assign(env, sentryEnv);
return env;
}
+30 -15
View File
@@ -1,8 +1,13 @@
// Load .env file FIRST before any other imports that might use process.env
import { config } from 'dotenv';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load .env from apps/frontend directory
// In development: __dirname is out/main (compiled), so go up 2 levels
// In production: app resources directory
@@ -359,24 +364,34 @@ app.whenReady().then(() => {
});
});
// Pre-initialize Claude profile manager in background (non-blocking)
// This ensures profile data is loaded before user clicks "Start Claude Code"
setImmediate(() => {
initializeClaudeProfileManager().catch((error) => {
console.warn('[main] Failed to pre-initialize profile manager:', error);
// Initialize Claude profile manager, then start usage monitor
// We do this sequentially to ensure profile data (including auto-switch settings)
// is loaded BEFORE the usage monitor attempts to read settings.
// This prevents the "UsageMonitor disabled" error due to race condition.
initializeClaudeProfileManager()
.then(() => {
// Only start monitoring if window is still available (app not quitting)
if (mainWindow) {
// Setup event forwarding from usage monitor to renderer
initializeUsageMonitorForwarding(mainWindow);
// Start the usage monitor
const usageMonitor = getUsageMonitor();
usageMonitor.start();
console.warn('[main] Usage monitor initialized and started (after profile load)');
}
})
.catch((error) => {
console.warn('[main] Failed to initialize profile manager:', error);
// Fallback: try starting usage monitor anyway (might use defaults)
if (mainWindow) {
initializeUsageMonitorForwarding(mainWindow);
const usageMonitor = getUsageMonitor();
usageMonitor.start();
}
});
});
// Initialize usage monitoring after window is created
if (mainWindow) {
// Setup event forwarding from usage monitor to renderer
initializeUsageMonitorForwarding(mainWindow);
// Start the usage monitor
const usageMonitor = getUsageMonitor();
usageMonitor.start();
console.warn('[main] Usage monitor initialized and started');
// Log debug mode status
const isDebugMode = process.env.DEBUG === 'true';
if (isDebugMode) {
@@ -13,7 +13,8 @@ import {
downloadUpdate,
downloadStableVersion,
quitAndInstall,
getCurrentVersion
getCurrentVersion,
getDownloadedUpdateInfo
} from '../app-updater';
/**
@@ -123,5 +124,28 @@ export function registerAppUpdateHandlers(): void {
}
);
/**
* APP_UPDATE_GET_DOWNLOADED: Get downloaded update info
* Returns info about a downloaded update that's ready to install,
* or null if no update has been downloaded yet.
* This allows the UI to show "Install and Restart" even if the user
* opens Settings after the download completed in the background.
*/
ipcMain.handle(
IPC_CHANNELS.APP_UPDATE_GET_DOWNLOADED,
async (): Promise<IPCResult<AppUpdateInfo | null>> => {
try {
const downloadedInfo = getDownloadedUpdateInfo();
return { success: true, data: downloadedInfo };
} catch (error) {
console.error('[app-update-handlers] Get downloaded update info failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get downloaded update info'
};
}
}
);
console.warn('[IPC] App update handlers registered successfully');
}
@@ -7,20 +7,199 @@
* - Open terminal with installation command
*/
import { ipcMain, shell } from 'electron';
import { execFileSync, spawn } from 'child_process';
import { existsSync, statSync } from 'fs';
import { ipcMain } from 'electron';
import { execFileSync, spawn, execFile } from 'child_process';
import { existsSync, promises as fsPromises } from 'fs';
import path from 'path';
import { IPC_CHANNELS } from '../../shared/constants/ipc';
import os from 'os';
import { promisify } from 'util';
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS } from '../../shared/constants';
import type { IPCResult } from '../../shared/types';
import type { ClaudeCodeVersionInfo } from '../../shared/types/cli';
import { getToolInfo } from '../cli-tool-manager';
import { readSettingsFile } from '../settings-utils';
import type { ClaudeCodeVersionInfo, ClaudeInstallationList, ClaudeInstallationInfo } from '../../shared/types/cli';
import { getToolInfo, configureTools, sortNvmVersionDirs, getClaudeDetectionPaths } from '../cli-tool-manager';
import { readSettingsFile, writeSettingsFile } from '../settings-utils';
import { isSecurePath } from '../utils/windows-paths';
import semver from 'semver';
const execFileAsync = promisify(execFile);
// Cache for latest version (avoid hammering npm registry)
let cachedLatestVersion: { version: string; timestamp: number } | null = null;
let cachedVersionList: { versions: string[]; timestamp: number } | null = null;
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
const VERSION_LIST_CACHE_DURATION_MS = 60 * 60 * 1000; // 1 hour for version list
/**
* Validate a Claude CLI path and get its version
* @param cliPath - Path to the Claude CLI executable
* @returns Tuple of [isValid, version or null]
*/
async function validateClaudeCliAsync(cliPath: string): Promise<[boolean, string | null]> {
try {
const isWindows = process.platform === 'win32';
// Augment PATH with the CLI directory for proper resolution
const cliDir = path.dirname(cliPath);
const env = {
...process.env,
PATH: cliDir ? `${cliDir}${path.delimiter}${process.env.PATH || ''}` : process.env.PATH,
};
let stdout: string;
// For Windows .cmd/.bat files, use cmd.exe with proper quoting
// /d = disable AutoRun registry commands
// /s = strip first and last quotes, preserving inner quotes
// /c = run command then terminate
if (isWindows && /\.(cmd|bat)$/i.test(cliPath)) {
// Get cmd.exe path from environment or use default
const cmdExe = process.env.ComSpec
|| path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe');
// Use double-quoted command line for paths with spaces
const cmdLine = `""${cliPath}" --version"`;
const result = await execFileAsync(cmdExe, ['/d', '/s', '/c', cmdLine], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env,
});
stdout = result.stdout;
} else {
const result = await execFileAsync(cliPath, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env,
});
stdout = result.stdout;
}
const version = String(stdout).trim();
const match = version.match(/(\d+\.\d+\.\d+)/);
return [true, match ? match[1] : version.split('\n')[0]];
} catch (error) {
// Log validation errors to help debug CLI detection issues
console.warn('[Claude Code] CLI validation failed for', cliPath, ':', error);
return [false, null];
}
}
/**
* Scan all known locations for Claude CLI installations.
* Returns all found installations with their paths, versions, and sources.
*
* Uses getClaudeDetectionPaths() from cli-tool-manager.ts as the single source
* of truth for detection paths to avoid duplication and ensure consistency.
*
* @see cli-tool-manager.ts getClaudeDetectionPaths() for path configuration
*/
async function scanClaudeInstallations(activePath: string | null): Promise<ClaudeInstallationInfo[]> {
const installations: ClaudeInstallationInfo[] = [];
const seenPaths = new Set<string>();
const homeDir = os.homedir();
const isWindows = process.platform === 'win32';
// Get detection paths from cli-tool-manager (single source of truth)
const detectionPaths = getClaudeDetectionPaths(homeDir);
const addInstallation = async (
cliPath: string,
source: ClaudeInstallationInfo['source']
) => {
// Normalize path for comparison
const normalizedPath = path.resolve(cliPath);
if (seenPaths.has(normalizedPath)) return;
if (!existsSync(cliPath)) return;
// Security validation: reject paths with shell metacharacters or directory traversal
if (!isSecurePath(cliPath)) {
console.warn('[Claude Code] Rejecting insecure path:', cliPath);
return;
}
const [isValid, version] = await validateClaudeCliAsync(cliPath);
if (!isValid) return;
seenPaths.add(normalizedPath);
installations.push({
path: normalizedPath,
version,
source,
isActive: activePath ? path.resolve(activePath) === normalizedPath : false,
});
};
// 1. Check user-configured path first (if set)
if (activePath && existsSync(activePath)) {
await addInstallation(activePath, 'user-config');
}
// 2. Check system PATH via which/where
try {
if (isWindows) {
const result = await execFileAsync('where', ['claude'], { timeout: 5000 });
const paths = result.stdout.trim().split('\n').filter(p => p.trim());
for (const p of paths) {
await addInstallation(p.trim(), 'system-path');
}
} else {
const result = await execFileAsync('which', ['-a', 'claude'], { timeout: 5000 });
const paths = result.stdout.trim().split('\n').filter(p => p.trim());
for (const p of paths) {
await addInstallation(p.trim(), 'system-path');
}
}
} catch {
// which/where failed, continue with other methods
}
// 3. Homebrew paths (macOS) - from getClaudeDetectionPaths
if (process.platform === 'darwin') {
for (const p of detectionPaths.homebrewPaths) {
await addInstallation(p, 'homebrew');
}
}
// 4. NVM paths (Unix) - check Node.js version manager
if (!isWindows && existsSync(detectionPaths.nvmVersionsDir)) {
try {
const entries = await fsPromises.readdir(detectionPaths.nvmVersionsDir, { withFileTypes: true });
const versionDirs = sortNvmVersionDirs(entries);
for (const versionName of versionDirs) {
const nvmClaudePath = path.join(detectionPaths.nvmVersionsDir, versionName, 'bin', 'claude');
await addInstallation(nvmClaudePath, 'nvm');
}
} catch {
// Failed to read NVM directory
}
}
// 5. Platform-specific standard locations - from getClaudeDetectionPaths
for (const p of detectionPaths.platformPaths) {
await addInstallation(p, 'system-path');
}
// 6. Additional common paths not in getClaudeDetectionPaths (for broader scanning)
const additionalPaths = isWindows
? [] // Windows paths are well covered by detectionPaths.platformPaths
: [
path.join(homeDir, '.npm-global', 'bin', 'claude'),
path.join(homeDir, '.yarn', 'bin', 'claude'),
path.join(homeDir, '.claude', 'local', 'claude'),
path.join(homeDir, 'node_modules', '.bin', 'claude'),
];
for (const p of additionalPaths) {
await addInstallation(p, 'system-path');
}
// Mark the first installation as active if none is explicitly active
if (installations.length > 0 && !installations.some(i => i.isActive)) {
installations[0].isActive = true;
}
return installations;
}
/**
* Fetch the latest version of Claude Code from npm registry
@@ -63,6 +242,74 @@ async function fetchLatestVersion(): Promise<string> {
}
}
/**
* Fetch available versions of Claude Code from npm registry
* Returns versions sorted by semver descending (newest first)
* Limited to last 20 versions for performance
*/
async function fetchAvailableVersions(): Promise<string[]> {
// Check cache first
if (cachedVersionList && Date.now() - cachedVersionList.timestamp < VERSION_LIST_CACHE_DURATION_MS) {
return cachedVersionList.versions;
}
try {
const response = await fetch('https://registry.npmjs.org/@anthropic-ai/claude-code', {
headers: {
'Accept': 'application/json',
},
signal: AbortSignal.timeout(15000), // 15 second timeout
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const versions = Object.keys(data.versions || {});
if (!versions.length) {
throw new Error('No versions found in npm registry');
}
// Sort by semver descending (newest first) and take last 20
const sortedVersions = versions
.filter(v => semver.valid(v)) // Only valid semver versions
.sort((a, b) => semver.rcompare(a, b)) // Sort descending
.slice(0, 20); // Limit to 20 versions
// Validate we have versions after filtering
if (sortedVersions.length === 0) {
throw new Error('No valid semver versions found in npm registry');
}
// Cache the result
cachedVersionList = { versions: sortedVersions, timestamp: Date.now() };
return sortedVersions;
} catch (error) {
console.error('[Claude Code] Failed to fetch available versions:', error);
// Return cached versions if available, even if expired
if (cachedVersionList) {
return cachedVersionList.versions;
}
throw error;
}
}
/**
* Get the platform-specific install command for a specific version of Claude Code
* @param version - The version to install (e.g., "1.0.5")
*/
function getInstallVersionCommand(version: string): string {
if (process.platform === 'win32') {
// Windows: kill running Claude processes first, then install specific version
return `taskkill /IM claude.exe /F 2>nul; claude install --force ${version}`;
} else {
// macOS/Linux: kill running Claude processes first, then install specific version
return `pkill -x claude 2>/dev/null; sleep 1; claude install --force ${version}`;
}
}
/**
* Get the platform-specific install command for Claude Code
* @param isUpdate - If true, Claude is already installed and we just need to update
@@ -280,7 +527,7 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
'C:\\Program Files\\Git\\git-bash.exe',
'C:\\Program Files (x86)\\Git\\git-bash.exe',
];
let gitBashPath = gitBashPaths.find(p => existsSync(p));
const gitBashPath = gitBashPaths.find(p => existsSync(p));
if (gitBashPath) {
await runWindowsCommand(`"${gitBashPath}" -c "${escapedBashCommand}"`);
} else {
@@ -621,5 +868,151 @@ export function registerClaudeCodeHandlers(): void {
}
);
// Get available Claude Code versions
ipcMain.handle(
IPC_CHANNELS.CLAUDE_CODE_GET_VERSIONS,
async (): Promise<IPCResult<{ versions: string[] }>> => {
try {
console.log('[Claude Code] Fetching available versions...');
const versions = await fetchAvailableVersions();
console.log('[Claude Code] Found', versions.length, 'versions');
return {
success: true,
data: { versions },
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error('[Claude Code] Failed to fetch versions:', errorMsg, error);
return {
success: false,
error: `Failed to fetch available versions: ${errorMsg}`,
};
}
}
);
// Install a specific version of Claude Code
ipcMain.handle(
IPC_CHANNELS.CLAUDE_CODE_INSTALL_VERSION,
async (_event, version: string): Promise<IPCResult<{ command: string; version: string }>> => {
try {
// Validate version format
if (!version || typeof version !== 'string') {
throw new Error('Invalid version specified');
}
// Basic semver validation
if (!semver.valid(version)) {
throw new Error(`Invalid version format: ${version}`);
}
console.log('[Claude Code] Installing version:', version);
const command = getInstallVersionCommand(version);
console.log('[Claude Code] Install command:', command);
console.log('[Claude Code] Opening terminal...');
await openTerminalWithCommand(command);
console.log('[Claude Code] Terminal opened successfully');
return {
success: true,
data: { command, version },
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error('[Claude Code] Install version failed:', errorMsg, error);
return {
success: false,
error: `Failed to install version: ${errorMsg}`,
};
}
}
);
// Get all Claude CLI installations found on the system
ipcMain.handle(
IPC_CHANNELS.CLAUDE_CODE_GET_INSTALLATIONS,
async (): Promise<IPCResult<ClaudeInstallationList>> => {
try {
console.log('[Claude Code] Scanning for installations...');
// Get current active path from settings
const settings = readSettingsFile();
const activePath = settings?.claudePath as string | undefined;
const installations = await scanClaudeInstallations(activePath || null);
console.log('[Claude Code] Found', installations.length, 'installations');
return {
success: true,
data: {
installations,
activePath: activePath || (installations.length > 0 ? installations[0].path : null),
},
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error('[Claude Code] Failed to scan installations:', errorMsg, error);
return {
success: false,
error: `Failed to scan Claude CLI installations: ${errorMsg}`,
};
}
}
);
// Set the active Claude CLI path
ipcMain.handle(
IPC_CHANNELS.CLAUDE_CODE_SET_ACTIVE_PATH,
async (_event, cliPath: string): Promise<IPCResult<{ path: string }>> => {
try {
console.log('[Claude Code] Setting active path:', cliPath);
// Security validation: reject paths with shell metacharacters or directory traversal
if (!isSecurePath(cliPath)) {
throw new Error('Invalid path: contains potentially unsafe characters');
}
// Normalize path to prevent directory traversal
const normalizedPath = path.resolve(cliPath);
// Validate the path exists and is executable
if (!existsSync(normalizedPath)) {
throw new Error('Claude CLI not found at specified path');
}
const [isValid, version] = await validateClaudeCliAsync(normalizedPath);
if (!isValid) {
throw new Error('Claude CLI at specified path is not valid or not executable');
}
// Save to settings using established pattern: merge with DEFAULT_APP_SETTINGS
const currentSettings = readSettingsFile() || {};
const mergedSettings = {
...DEFAULT_APP_SETTINGS,
...currentSettings,
claudePath: normalizedPath,
} as Record<string, unknown>;
writeSettingsFile(mergedSettings);
// Update CLI tool manager cache
configureTools({ claudePath: normalizedPath });
console.log('[Claude Code] Active path set:', normalizedPath, 'version:', version);
return {
success: true,
data: { path: normalizedPath },
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error('[Claude Code] Failed to set active path:', errorMsg, error);
return {
success: false,
error: `Failed to set active Claude CLI path: ${errorMsg}`,
};
}
}
);
console.warn('[IPC] Claude Code handlers registered');
}
@@ -9,7 +9,12 @@ import { spawn, exec } from 'child_process';
import type { ChildProcess } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import type { Project } from '../../../../shared/types';
import { parsePythonCommand } from '../../../python-detector';
@@ -194,29 +194,46 @@ export async function convertIdeaToTask(
AUTO_BUILD_PATHS.IDEATION_FILE
);
const ideation = readIdeationFile(ideationPath);
if (!ideation) {
// Quick check that ideation file exists (actual read happens inside lock)
if (!existsSync(ideationPath)) {
return { success: false, error: 'Ideation not found' };
}
// Get specs directory path
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specsDir = path.join(project.path, specsBaseDir);
// Ensure specs directory exists
if (!existsSync(specsDir)) {
mkdirSync(specsDir, { recursive: true });
}
try {
// Find the idea
const idea = ideation.ideas?.find((i) => i.id === ideaId);
if (!idea) {
return { success: false, error: 'Idea not found' };
}
// Get specs directory path
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specsDir = path.join(project.path, specsBaseDir);
// Ensure specs directory exists
if (!existsSync(specsDir)) {
mkdirSync(specsDir, { recursive: true });
}
// Use coordinated spec numbering with lock to prevent collisions
// CRITICAL: All state checks must happen INSIDE the lock to prevent TOCTOU race conditions
return await withSpecNumberLock(project.path, async (lock) => {
// Re-read ideation file INSIDE the lock to get fresh state
const ideation = readIdeationFile(ideationPath);
if (!ideation) {
return { success: false, error: 'Ideation not found' };
}
// Find the idea (inside lock for fresh state)
const idea = ideation.ideas?.find((i) => i.id === ideaId);
if (!idea) {
return { success: false, error: 'Idea not found' };
}
// Idempotency check INSIDE lock - prevents TOCTOU race condition
// Two concurrent requests can both pass an outside check, but only one
// can hold the lock at a time, so this check is authoritative
if (idea.linked_task_id) {
return {
success: false,
error: `Idea has already been converted to task: ${idea.linked_task_id}`
};
}
// Get next spec number from global scan (main + all worktrees)
const nextNum = lock.getNextSpecNumber(project.autoBuildPath);
const slugifiedTitle = slugifyTitle(idea.title);
@@ -8,8 +8,13 @@
import { ipcMain, app } from 'electron';
import { spawn, execFileSync } from 'child_process';
import * as path from 'path';
import { fileURLToPath } from 'url';
import * as fs from 'fs';
import * as os from 'os';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { IPC_CHANNELS } from '../../shared/constants';
import type {
IPCResult,
@@ -2,7 +2,12 @@ import { ipcMain, dialog, app, shell } from 'electron';
import { existsSync, writeFileSync, mkdirSync, statSync, readFileSync } from 'fs';
import { execFileSync } from 'node:child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import { is } from '@electron-toolkit/utils';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES } from '../../shared/constants';
import type {
AppSettings,
@@ -1,15 +1,15 @@
import { ipcMain, BrowserWindow } from 'electron';
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types';
import type { IPCResult, TaskStartOptions, TaskStatus, ImageAttachment } from '../../../shared/types';
import path from 'path';
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'fs';
import { spawnSync, execFileSync } from 'child_process';
import { getToolPath } from '../../cli-tool-manager';
import { AgentManager } from '../../agent';
import { fileWatcher } from '../../file-watcher';
import { findTaskAndProject } from './shared';
import { checkGitStatus } from '../../project-initializer';
import { getClaudeProfileManager } from '../../claude-profile-manager';
import { initializeClaudeProfileManager, type ClaudeProfileManager } from '../../claude-profile-manager';
import {
getPlanPath,
persistPlanStatus,
@@ -74,6 +74,30 @@ function checkSubtasksCompletion(plan: Record<string, unknown> | null): {
return { allSubtasks, completedCount, totalCount, allCompleted };
}
/**
* Helper function to ensure profile manager is initialized.
* Returns a discriminated union for type-safe error handling.
*
* @returns Success with profile manager, or failure with error message
*/
async function ensureProfileManagerInitialized(): Promise<
| { success: true; profileManager: ClaudeProfileManager }
| { success: false; error: string }
> {
try {
const profileManager = await initializeClaudeProfileManager();
return { success: true, profileManager };
} catch (error) {
console.error('[ensureProfileManagerInitialized] Failed to initialize:', error);
// Include actual error details for debugging while providing actionable guidance
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to initialize profile manager. Please check file permissions and disk space. (${errorMessage})`
};
}
}
/**
* Register task execution handlers (start, stop, review, status management, recovery)
*/
@@ -86,7 +110,7 @@ export function registerTaskExecutionHandlers(
*/
ipcMain.on(
IPC_CHANNELS.TASK_START,
(_, taskId: string, _options?: TaskStartOptions) => {
async (_, taskId: string, _options?: TaskStartOptions) => {
console.warn('[TASK_START] Received request for taskId:', taskId);
const mainWindow = getMainWindow();
if (!mainWindow) {
@@ -94,6 +118,19 @@ export function registerTaskExecutionHandlers(
return;
}
// Ensure profile manager is initialized before checking auth
// This prevents race condition where auth check runs before profile data loads from disk
const initResult = await ensureProfileManagerInitialized();
if (!initResult.success) {
mainWindow.webContents.send(
IPC_CHANNELS.TASK_ERROR,
taskId,
initResult.error
);
return;
}
const profileManager = initResult.profileManager;
// Find task and project
const { task, project } = findTaskAndProject(taskId);
@@ -129,7 +166,6 @@ export function registerTaskExecutionHandlers(
}
// Check authentication - Claude requires valid auth to run tasks
const profileManager = getClaudeProfileManager();
if (!profileManager.hasValidAuth()) {
console.warn('[TASK_START] No valid authentication for active profile');
mainWindow.webContents.send(
@@ -318,7 +354,8 @@ export function registerTaskExecutionHandlers(
_,
taskId: string,
approved: boolean,
feedback?: string
feedback?: string,
images?: ImageAttachment[]
): Promise<IPCResult> => {
// Find task and project
const { task, project } = findTaskAndProject(taskId);
@@ -407,10 +444,65 @@ export function registerTaskExecutionHandlers(
console.warn('[TASK_REVIEW] Writing QA fix request to:', fixRequestPath);
console.warn('[TASK_REVIEW] hasWorktree:', hasWorktree, 'worktreePath:', worktreePath);
// Process images if provided
let imageReferences = '';
if (images && images.length > 0) {
const imagesDir = path.join(targetSpecDir, 'feedback_images');
try {
if (!existsSync(imagesDir)) {
mkdirSync(imagesDir, { recursive: true });
}
const savedImages: string[] = [];
for (const image of images) {
try {
if (!image.data) {
console.warn('[TASK_REVIEW] Skipping image with no data:', image.filename);
continue;
}
// Server-side MIME type validation (defense in depth - frontend also validates)
// Reject missing mimeType to prevent bypass attacks
const ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/svg+xml'];
if (!image.mimeType || !ALLOWED_MIME_TYPES.includes(image.mimeType)) {
console.warn('[TASK_REVIEW] Skipping image with missing or disallowed MIME type:', image.mimeType);
continue;
}
// Sanitize filename to prevent path traversal attacks
const sanitizedFilename = path.basename(image.filename);
if (!sanitizedFilename || sanitizedFilename === '.' || sanitizedFilename === '..') {
console.warn('[TASK_REVIEW] Skipping image with invalid filename:', image.filename);
continue;
}
// Remove data URL prefix if present (e.g., "data:image/png;base64," or "data:image/svg+xml;base64,")
const base64Data = image.data.replace(/^data:image\/[^;]+;base64,/, '');
const imageBuffer = Buffer.from(base64Data, 'base64');
const imagePath = path.join(imagesDir, sanitizedFilename);
// Verify the resolved path is within the images directory (defense in depth)
const resolvedPath = path.resolve(imagePath);
const resolvedImagesDir = path.resolve(imagesDir);
if (!resolvedPath.startsWith(resolvedImagesDir + path.sep)) {
console.warn('[TASK_REVIEW] Skipping image with path outside target directory:', image.filename);
continue;
}
writeFileSync(imagePath, imageBuffer);
savedImages.push(`feedback_images/${sanitizedFilename}`);
console.log('[TASK_REVIEW] Saved image:', sanitizedFilename);
} catch (imgError) {
console.error('[TASK_REVIEW] Failed to save image:', image.filename, imgError);
}
}
if (savedImages.length > 0) {
imageReferences = '\n\n## Reference Images\n\n' +
savedImages.map(imgPath => `![Feedback Image](${imgPath})`).join('\n\n');
}
} catch (dirError) {
console.error('[TASK_REVIEW] Failed to create images directory:', dirError);
}
}
try {
writeFileSync(
fixRequestPath,
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n`
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}${imageReferences}\n\nCreated at: ${new Date().toISOString()}\n`
);
} catch (error) {
console.error('[TASK_REVIEW] Failed to write QA fix request:', error);
@@ -609,7 +701,19 @@ export function registerTaskExecutionHandlers(
}
// Check authentication before auto-starting
const profileManager = getClaudeProfileManager();
// Ensure profile manager is initialized to prevent race condition
const initResult = await ensureProfileManagerInitialized();
if (!initResult.success) {
if (mainWindow) {
mainWindow.webContents.send(
IPC_CHANNELS.TASK_ERROR,
taskId,
initResult.error
);
}
return { success: false, error: initResult.error };
}
const profileManager = initResult.profileManager;
if (!profileManager.hasValidAuth()) {
console.warn('[TASK_UPDATE_STATUS] No valid authentication for active profile');
if (mainWindow) {
@@ -940,7 +1044,22 @@ export function registerTaskExecutionHandlers(
}
// Check authentication before auto-restarting
const profileManager = getClaudeProfileManager();
// Ensure profile manager is initialized to prevent race condition
const initResult = await ensureProfileManagerInitialized();
if (!initResult.success) {
// Recovery succeeded but we can't restart without profile manager
return {
success: true,
data: {
taskId,
recovered: true,
newStatus,
message: `Task recovered but cannot restart: ${initResult.error}`,
autoRestarted: false
}
};
}
const profileManager = initResult.profileManager;
if (!profileManager.hasValidAuth()) {
console.warn('[Recovery] Auth check failed, cannot auto-restart task');
// Recovery succeeded but we can't restart without auth
@@ -10,6 +10,7 @@ import { terminalNameGenerator } from '../terminal-name-generator';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape';
import { getClaudeCliInvocationAsync } from '../claude-cli-utils';
import { readSettingsFileAsync } from '../settings-utils';
/**
@@ -54,8 +55,17 @@ export function registerTerminalHandlers(
ipcMain.on(
IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE,
(_, id: string, cwd?: string) => {
// Use async version to avoid blocking main process during CLI detection
terminalManager.invokeClaudeAsync(id, cwd).catch((error) => {
// Wrap in async IIFE to allow async settings read without blocking
(async () => {
// Read settings asynchronously to check for YOLO mode (dangerously skip permissions)
const settings = await readSettingsFileAsync();
const dangerouslySkipPermissions = settings?.dangerouslySkipPermissions === true;
debugLog('[terminal-handlers] Invoking Claude with dangerouslySkipPermissions:', dangerouslySkipPermissions);
// Use async version to avoid blocking main process during CLI detection
await terminalManager.invokeClaudeAsync(id, cwd, undefined, dangerouslySkipPermissions);
})().catch((error) => {
debugError('[terminal-handlers] Failed to invoke Claude:', error);
});
}
@@ -138,7 +138,7 @@ function isValidProjectPath(projectPath: string): boolean {
return projects.some(p => p.path === projectPath);
}
const MAX_TERMINAL_WORKTREES = 12;
// No limit on terminal worktrees - users can create as many as needed
/**
* Get the default branch from project settings OR env config
@@ -267,14 +267,6 @@ async function createTerminalWorktree(
};
}
const existing = await listTerminalWorktrees(projectPath);
if (existing.length >= MAX_TERMINAL_WORKTREES) {
return {
success: false,
error: `Maximum of ${MAX_TERMINAL_WORKTREES} terminal worktrees reached.`,
};
}
// Auto-fix any misconfigured bare repo before worktree operations
// This prevents crashes when git worktree operations have incorrectly set bare=true
if (fixMisconfiguredBareRepo(projectPath)) {
+5
View File
@@ -9,8 +9,13 @@
import { spawn } from 'child_process';
import * as path from 'path';
import { fileURLToPath } from 'url';
import * as fs from 'fs';
import { app } from 'electron';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { findPythonCommand, parsePythonCommand } from './python-detector';
import { getConfiguredPythonPath, pythonEnvManager } from './python-env-manager';
import { getMemoriesDir } from './config-paths';
+56 -17
View File
@@ -23,34 +23,45 @@ import {
type SentryErrorEvent
} from '../shared/utils/sentry-privacy';
/**
* Build-time constants defined in electron.vite.config.ts
* These are replaced at build time with actual values from environment variables.
* In development, they come from .env file. In CI builds, from GitHub secrets.
*/
declare const __SENTRY_DSN__: string;
declare const __SENTRY_TRACES_SAMPLE_RATE__: string;
declare const __SENTRY_PROFILES_SAMPLE_RATE__: string;
// In-memory state for current setting (updated via IPC when user toggles)
let sentryEnabledState = true;
/**
* Get Sentry DSN from environment variable
* Get Sentry DSN from build-time constant
*
* For local development/testing:
* - Add SENTRY_DSN to your .env file, or
* - Run: SENTRY_DSN=your-dsn npm start
*
* For CI/CD releases:
* - Set SENTRY_DSN as a GitHub Actions secret
*
* For forks:
* - Without SENTRY_DSN, Sentry is disabled (safe for forks)
* The DSN is embedded at build time via Vite's `define` option.
* - In local development: comes from .env file (loaded by dotenv)
* - In CI builds: comes from GitHub secrets
* - For forks: without SENTRY_DSN, Sentry is disabled (safe for forks)
*/
function getSentryDsn(): string {
return process.env.SENTRY_DSN || '';
// __SENTRY_DSN__ is replaced at build time with the actual value
// Falls back to runtime env var for development flexibility
// typeof guard needed for test environments where Vite's define doesn't apply
const buildTimeValue = typeof __SENTRY_DSN__ !== 'undefined' ? __SENTRY_DSN__ : '';
return buildTimeValue || process.env.SENTRY_DSN || '';
}
/**
* Get trace sample rate from environment variable
* Get trace sample rate from build-time constant
* Controls performance monitoring sampling (0.0 to 1.0)
* Default: 0.1 (10%) in production, 0 in development
*/
function getTracesSampleRate(): number {
const envValue = process.env.SENTRY_TRACES_SAMPLE_RATE;
if (envValue !== undefined) {
// Try build-time constant first, then runtime env var
// typeof guard needed for test environments where Vite's define doesn't apply
const buildTimeValue = typeof __SENTRY_TRACES_SAMPLE_RATE__ !== 'undefined' ? __SENTRY_TRACES_SAMPLE_RATE__ : '';
const envValue = buildTimeValue || process.env.SENTRY_TRACES_SAMPLE_RATE;
if (envValue) {
const parsed = parseFloat(envValue);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
return parsed;
@@ -61,13 +72,16 @@ function getTracesSampleRate(): number {
}
/**
* Get profile sample rate from environment variable
* Get profile sample rate from build-time constant
* Controls profiling sampling relative to traces (0.0 to 1.0)
* Default: 0.1 (10%) in production, 0 in development
*/
function getProfilesSampleRate(): number {
const envValue = process.env.SENTRY_PROFILES_SAMPLE_RATE;
if (envValue !== undefined) {
// Try build-time constant first, then runtime env var
// typeof guard needed for test environments where Vite's define doesn't apply
const buildTimeValue = typeof __SENTRY_PROFILES_SAMPLE_RATE__ !== 'undefined' ? __SENTRY_PROFILES_SAMPLE_RATE__ : '';
const envValue = buildTimeValue || process.env.SENTRY_PROFILES_SAMPLE_RATE;
if (envValue) {
const parsed = parseFloat(envValue);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
return parsed;
@@ -165,3 +179,28 @@ export function setSentryEnabled(enabled: boolean): void {
sentryEnabledState = enabled;
console.log(`[Sentry] Error reporting ${enabled ? 'enabled' : 'disabled'} (programmatic)`);
}
/**
* Get Sentry environment variables for passing to Python subprocesses
*
* This returns the build-time embedded values so that Python backends
* can also report errors to Sentry in packaged apps.
*
* Usage:
* ```typescript
* const env = { ...getAugmentedEnv(), ...getSentryEnvForSubprocess() };
* spawn(pythonPath, args, { env });
* ```
*/
export function getSentryEnvForSubprocess(): Record<string, string> {
const dsn = getSentryDsn();
if (!dsn) {
return {};
}
return {
SENTRY_DSN: dsn,
SENTRY_TRACES_SAMPLE_RATE: String(getTracesSampleRate()),
SENTRY_PROFILES_SAMPLE_RATE: String(getProfilesSampleRate()),
};
}
+47 -1
View File
@@ -9,7 +9,8 @@
*/
import { app } from 'electron';
import { existsSync, readFileSync } from 'fs';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { promises as fsPromises } from 'fs';
import path from 'path';
/**
@@ -41,3 +42,48 @@ export function readSettingsFile(): Record<string, unknown> | undefined {
return undefined;
}
}
/**
* Write settings to disk.
*
* @param settings - The settings object to write
*/
export function writeSettingsFile(settings: Record<string, unknown>): void {
const settingsPath = getSettingsPath();
// Ensure the directory exists
const dir = path.dirname(settingsPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
}
/**
* Read and parse settings from disk asynchronously.
* Returns the raw parsed settings object, or undefined if the file doesn't exist or fails to parse.
*
* This is the non-blocking version of readSettingsFile, safe to use in Electron main process
* without blocking the event loop.
*
* This function does NOT merge with defaults or perform any migrations.
* Callers are responsible for merging with DEFAULT_APP_SETTINGS.
*/
export async function readSettingsFileAsync(): Promise<Record<string, unknown> | undefined> {
const settingsPath = getSettingsPath();
try {
await fsPromises.access(settingsPath);
} catch {
return undefined;
}
try {
const content = await fsPromises.readFile(settingsPath, 'utf-8');
return JSON.parse(content);
} catch {
// Return undefined on parse error - caller will use defaults
return undefined;
}
}
@@ -1,7 +1,12 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { existsSync, readFileSync } from 'fs';
import { spawn } from 'child_process';
import { app } from 'electron';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { EventEmitter } from 'events';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
import { parsePythonCommand } from './python-detector';
@@ -1,8 +1,10 @@
import { writeFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type * as pty from '@lydell/node-pty';
import type { TerminalProcess } from '../types';
import { buildCdCommand } from '../../../shared/utils/shell-escape';
/** Escape special regex characters in a string for safe use in RegExp constructor */
const escapeForRegex = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -100,7 +102,7 @@ describe('claude-integration-handler', () => {
invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn());
const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
expect(written).toContain("cd '/tmp/project' && ");
expect(written).toContain(buildCdCommand('/tmp/project'));
expect(written).toContain("PATH='/opt/claude/bin:/usr/bin' ");
expect(written).toContain("'/opt/claude bin/claude'\\''s'");
expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1');
@@ -233,8 +235,8 @@ describe('claude-integration-handler', () => {
const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string;
const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string;
const tmpDir = escapeForRegex(tmpdir());
expect(tokenPath).toMatch(new RegExp(`^${tmpDir}/\\.claude-token-1234-[0-9a-f]{16}$`));
const tokenPrefix = path.join(tmpdir(), '.claude-token-1234-');
expect(tokenPath).toMatch(new RegExp(`^${escapeForRegex(tokenPrefix)}[0-9a-f]{16}$`));
expect(tokenContents).toBe("export CLAUDE_CODE_OAUTH_TOKEN='token-value'\n");
const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
expect(written).toContain("HISTFILE= HISTCONTROL=ignorespace ");
@@ -276,8 +278,8 @@ describe('claude-integration-handler', () => {
const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string;
const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string;
const tmpDir = escapeForRegex(tmpdir());
expect(tokenPath).toMatch(new RegExp(`^${tmpDir}/\\.claude-token-5678-[0-9a-f]{16}$`));
const tokenPrefix = path.join(tmpdir(), '.claude-token-5678-');
expect(tokenPath).toMatch(new RegExp(`^${escapeForRegex(tokenPrefix)}[0-9a-f]{16}$`));
expect(tokenContents).toBe("export CLAUDE_CODE_OAUTH_TOKEN='token-value'\n");
const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string;
expect(written).toContain(`source '${tokenPath}'`);
@@ -0,0 +1,258 @@
import { describe, expect, it } from 'vitest';
import {
detectClaudeExit,
isClaudeExitOutput,
isClaudeBusyOutput,
isClaudeIdleOutput,
detectClaudeBusyState,
extractClaudeSessionId,
extractRateLimitReset,
extractOAuthToken,
extractEmail,
hasRateLimitMessage,
hasOAuthToken,
} from '../output-parser';
describe('output-parser', () => {
describe('detectClaudeExit', () => {
describe('should detect shell prompts (Claude has exited)', () => {
const shellPrompts = [
// user@hostname patterns
{ input: 'user@hostname:~$ ', desc: 'bash: user@hostname:~$' },
{ input: 'user@host:~/projects$ ', desc: 'bash with path' },
{ input: 'root@server:/var/log# ', desc: 'root prompt' },
{ input: 'dev@localhost:~ % ', desc: 'zsh style' },
// Bracket prompts
{ input: '[user@host directory]$ ', desc: 'bracket prompt' },
{ input: ' [user@host ~]$ ', desc: 'bracket prompt with leading space' },
// Virtual environment prompts
{ input: '(venv) user@host:~$ ', desc: 'venv prompt' },
{ input: '(base) $ ', desc: 'conda base prompt' },
{ input: '(myenv) ~/projects $ ', desc: 'venv with path' },
// Starship/Oh-My-Zsh prompts
{ input: ' ', desc: 'starship arrow' },
{ input: ' ', desc: 'starship with space' },
{ input: '➜ ', desc: 'oh-my-zsh arrow' },
{ input: 'λ ', desc: 'lambda prompt' },
// Fish shell prompts
{ input: '~/projects> ', desc: 'fish path prompt' },
{ input: '/home/user> ', desc: 'fish absolute path' },
// Git branch prompts
{ input: '(main) $ ', desc: 'git branch prompt' },
{ input: '[feature/test] > ', desc: 'git branch in brackets' },
// Simple hostname prompts
{ input: 'hostname$ ', desc: 'simple hostname$' },
{ input: 'myserver% ', desc: 'hostname with %' },
// Explicit exit messages
{ input: 'Goodbye!', desc: 'goodbye message' },
{ input: 'Session ended', desc: 'session ended' },
{ input: 'Exiting Claude', desc: 'exiting claude' },
];
it.each(shellPrompts)('detects: $desc', ({ input }) => {
expect(detectClaudeExit(input)).toBe(true);
});
});
describe('should NOT detect these as exit (Claude is still active or these are Claude output)', () => {
const notExitPatterns = [
// Claude's idle prompt
{ input: '> ', desc: 'Claude idle prompt (just >)' },
{ input: '\n> ', desc: 'Claude idle prompt after newline' },
// Claude busy indicators
{ input: '● Working on your request...', desc: 'Claude bullet point' },
{ input: 'Loading...', desc: 'Claude loading' },
{ input: 'Read(/path/to/file.ts)', desc: 'Claude tool execution' },
// Content that could false-positive match if not anchored
{ input: 'The path is ~/config $HOME', desc: 'path in explanation (should NOT match)' },
{ input: 'Use arr[index] to access elements', desc: 'array access in explanation' },
{ input: 'See the arrow → for details', desc: 'arrow in text' },
{ input: 'File path: ~/projects/test.js', desc: 'file path mid-line' },
{ input: 'Contact user@example.com for help', desc: 'email in text (mid-line)' },
{ input: 'user@example.com: please review this', desc: 'email at line start with colon (should NOT match)' },
{ input: 'admin@company.org: check the logs', desc: 'email at line start with text after colon' },
{ input: 'The variable $HOME is set to /Users/dev', desc: 'shell var in explanation' },
{ input: 'Example: (main) branch is default', desc: 'branch name in explanation' },
// Progress indicators
{ input: '[Opus 4] 50%', desc: 'Opus progress' },
{ input: '███████░░░ 70%', desc: 'progress bar' },
];
it.each(notExitPatterns)('ignores: $desc', ({ input }) => {
expect(detectClaudeExit(input)).toBe(false);
});
});
describe('should return false when Claude is busy even if shell-like patterns appear', () => {
it('returns false when busy indicators present with shell pattern', () => {
// This tests the guard in detectClaudeExit that checks busy state first
const mixedOutput = '● Processing...\nuser@host:~$ ';
expect(detectClaudeExit(mixedOutput)).toBe(false);
});
});
});
describe('isClaudeExitOutput', () => {
it('detects user@hostname pattern at line start', () => {
expect(isClaudeExitOutput('user@hostname:~$ ')).toBe(true);
expect(isClaudeExitOutput('dev@server:/home$ ')).toBe(true);
});
it('does not match user@hostname in middle of line', () => {
// With the line-start anchor, this should NOT match
expect(isClaudeExitOutput('Contact user@hostname.com for help')).toBe(false);
});
it('detects goodbye message', () => {
expect(isClaudeExitOutput('Goodbye!')).toBe(true);
expect(isClaudeExitOutput('Goodbye')).toBe(true);
});
it('detects session ended', () => {
expect(isClaudeExitOutput('Session ended')).toBe(true);
});
});
describe('isClaudeBusyOutput', () => {
const busyPatterns = [
'● Here is my response',
'Read(/path/to/file.ts)',
'Write(/output.json)',
'Loading...',
'Thinking...',
'Analyzing...',
'[Opus 4] 25%',
'[Sonnet 3.5] 50%',
'██████░░░░',
];
it.each(busyPatterns)('detects busy pattern: %s', (input) => {
expect(isClaudeBusyOutput(input)).toBe(true);
});
it('returns false for normal text', () => {
expect(isClaudeBusyOutput('Hello, how can I help?')).toBe(false);
});
});
describe('isClaudeIdleOutput', () => {
it('detects Claude idle prompt', () => {
expect(isClaudeIdleOutput('> ')).toBe(true);
expect(isClaudeIdleOutput('\n> ')).toBe(true);
expect(isClaudeIdleOutput(' > ')).toBe(true);
});
it('returns false for non-idle output', () => {
expect(isClaudeIdleOutput('Hello')).toBe(false);
expect(isClaudeIdleOutput('● Working')).toBe(false);
});
});
describe('detectClaudeBusyState', () => {
it('returns "busy" when busy indicators present', () => {
expect(detectClaudeBusyState('● Thinking...')).toBe('busy');
expect(detectClaudeBusyState('Loading...')).toBe('busy');
});
it('returns "idle" when idle prompt present and no busy indicators', () => {
expect(detectClaudeBusyState('> ')).toBe('idle');
});
it('returns null when no state change detected', () => {
expect(detectClaudeBusyState('Hello')).toBe(null);
});
it('prioritizes busy over idle', () => {
// If both patterns present, busy should win
expect(detectClaudeBusyState('● Working\n> ')).toBe('busy');
});
});
describe('extractClaudeSessionId', () => {
it('extracts session ID from "Session ID: xxx" format', () => {
expect(extractClaudeSessionId('Session ID: abc123')).toBe('abc123');
expect(extractClaudeSessionId('Session: xyz789')).toBe('xyz789');
});
it('extracts session ID from "Resuming session: xxx"', () => {
expect(extractClaudeSessionId('Resuming session: sess_456')).toBe('sess_456');
});
it('returns null when no session ID found', () => {
expect(extractClaudeSessionId('Hello world')).toBe(null);
});
});
describe('extractRateLimitReset', () => {
it('extracts rate limit reset time', () => {
expect(extractRateLimitReset('Limit reached · resets Dec 17 at 6am (Europe/Oslo)')).toBe('Dec 17 at 6am (Europe/Oslo)');
expect(extractRateLimitReset('Limit reached • resets Jan 1 at noon')).toBe('Jan 1 at noon');
});
it('returns null when no rate limit message', () => {
expect(extractRateLimitReset('Normal output')).toBe(null);
});
});
describe('hasRateLimitMessage', () => {
it('returns true when rate limit message present', () => {
expect(hasRateLimitMessage('Limit reached · resets tomorrow')).toBe(true);
});
it('returns false when no rate limit message', () => {
expect(hasRateLimitMessage('Normal text')).toBe(false);
});
});
describe('extractOAuthToken', () => {
it('extracts OAuth token', () => {
const token = 'sk-ant-oat01-abc123_XYZ';
expect(extractOAuthToken(`Token: ${token}`)).toBe(token);
});
it('returns null when no token present', () => {
expect(extractOAuthToken('No token here')).toBe(null);
});
});
describe('hasOAuthToken', () => {
it('returns true when OAuth token present', () => {
expect(hasOAuthToken('sk-ant-oat01-test123')).toBe(true);
});
it('returns false when no token', () => {
expect(hasOAuthToken('No token')).toBe(false);
});
});
describe('extractEmail', () => {
it('extracts email from "email:" format', () => {
expect(extractEmail('email: user@example.com')).toBe('user@example.com');
expect(extractEmail('email:dev@company.org')).toBe('dev@company.org');
});
it('extracts email with whitespace after "email"', () => {
expect(extractEmail('email test@domain.com')).toBe('test@domain.com');
});
it('returns null when no email found', () => {
expect(extractEmail('No email here')).toBe(null);
});
it('returns null for "Authenticated as" with space before email', () => {
// Note: The current pattern expects email immediately after "as"
// This documents the actual behavior of the implementation
expect(extractEmail('Authenticated as user@example.com')).toBe(null);
});
});
});
@@ -26,6 +26,12 @@ function normalizePathForBash(envPath: string): string {
return process.platform === 'win32' ? envPath.replace(/;/g, ':') : envPath;
}
/**
* Flag for YOLO mode (skip all permission prompts)
* Extracted as constant to ensure consistency across invokeClaude and invokeClaudeAsync
*/
const YOLO_MODE_FLAG = ' --dangerously-skip-permissions';
// ============================================================================
// SHARED HELPERS - Used by both sync and async invokeClaude
// ============================================================================
@@ -54,6 +60,7 @@ type ClaudeCommandConfig =
* @param pathPrefix - PATH prefix for Claude CLI (empty string if not needed)
* @param escapedClaudeCmd - Shell-escaped Claude CLI command
* @param config - Configuration object with method and required options (discriminated union)
* @param extraFlags - Optional extra flags to append to the command (e.g., '--dangerously-skip-permissions')
* @returns Complete shell command string ready for terminal.pty.write()
*
* @example
@@ -69,17 +76,19 @@ export function buildClaudeShellCommand(
cwdCommand: string,
pathPrefix: string,
escapedClaudeCmd: string,
config: ClaudeCommandConfig
config: ClaudeCommandConfig,
extraFlags?: string
): string {
const fullCmd = extraFlags ? `${escapedClaudeCmd}${extraFlags}` : escapedClaudeCmd;
switch (config.method) {
case 'temp-file':
return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${config.escapedTempFile} && rm -f ${config.escapedTempFile} && exec ${escapedClaudeCmd}"\r`;
return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${config.escapedTempFile} && rm -f ${config.escapedTempFile} && exec ${fullCmd}"\r`;
case 'config-dir':
return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${config.escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`;
return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${config.escapedConfigDir} ${pathPrefix}bash -c "exec ${fullCmd}"\r`;
default:
return `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`;
return `${cwdCommand}${pathPrefix}${fullCmd}\r`;
}
}
@@ -329,6 +338,40 @@ export function handleClaudeSessionId(
}
}
/**
* Handle Claude exit detection (user closed Claude, returned to shell)
*
* This is called when we detect that Claude has exited and the terminal
* has returned to a shell prompt. This resets the Claude mode state
* and notifies the renderer to update the UI.
*/
export function handleClaudeExit(
terminal: TerminalProcess,
getWindow: WindowGetter
): void {
// Only handle if we're actually in Claude mode
if (!terminal.isClaudeMode) {
return;
}
console.warn('[ClaudeIntegration] Claude exit detected, resetting mode for terminal:', terminal.id);
// Reset Claude mode state
terminal.isClaudeMode = false;
terminal.claudeSessionId = undefined;
// Persist the session state change
if (terminal.projectPath) {
SessionHandler.persistSession(terminal);
}
// Notify renderer to update UI
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, terminal.id);
}
}
/**
* Invoke Claude with optional profile override
*/
@@ -337,14 +380,21 @@ export function invokeClaude(
cwd: string | undefined,
profileId: string | undefined,
getWindow: WindowGetter,
onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void
onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void,
dangerouslySkipPermissions?: boolean
): void {
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE START ==========');
debugLog('[ClaudeIntegration:invokeClaude] Terminal ID:', terminal.id);
debugLog('[ClaudeIntegration:invokeClaude] Requested profile ID:', profileId);
debugLog('[ClaudeIntegration:invokeClaude] CWD:', cwd);
debugLog('[ClaudeIntegration:invokeClaude] Dangerously skip permissions:', dangerouslySkipPermissions);
// Compute extra flags for YOLO mode
const extraFlags = dangerouslySkipPermissions ? YOLO_MODE_FLAG : undefined;
terminal.isClaudeMode = true;
// Store YOLO mode setting so it persists across profile switches
terminal.dangerouslySkipPermissions = dangerouslySkipPermissions;
SessionHandler.releaseSessionId(terminal.id);
terminal.claudeSessionId = undefined;
@@ -399,7 +449,7 @@ export function invokeClaude(
{ mode: 0o600 }
);
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile });
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }, extraFlags);
debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)');
terminal.pty.write(command);
profileManager.markProfileUsed(activeProfile.id);
@@ -408,7 +458,7 @@ export function invokeClaude(
return;
} else if (activeProfile.configDir) {
const escapedConfigDir = escapeShellArg(activeProfile.configDir);
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir });
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }, extraFlags);
debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)');
terminal.pty.write(command);
profileManager.markProfileUsed(activeProfile.id);
@@ -424,7 +474,7 @@ export function invokeClaude(
debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name);
}
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' });
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags);
debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command);
terminal.pty.write(command);
@@ -506,14 +556,21 @@ export async function invokeClaudeAsync(
cwd: string | undefined,
profileId: string | undefined,
getWindow: WindowGetter,
onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void
onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void,
dangerouslySkipPermissions?: boolean
): Promise<void> {
debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE START (async) ==========');
debugLog('[ClaudeIntegration:invokeClaudeAsync] Terminal ID:', terminal.id);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Requested profile ID:', profileId);
debugLog('[ClaudeIntegration:invokeClaudeAsync] CWD:', cwd);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Dangerously skip permissions:', dangerouslySkipPermissions);
// Compute extra flags for YOLO mode
const extraFlags = dangerouslySkipPermissions ? YOLO_MODE_FLAG : undefined;
terminal.isClaudeMode = true;
// Store YOLO mode setting so it persists across profile switches
terminal.dangerouslySkipPermissions = dangerouslySkipPermissions;
SessionHandler.releaseSessionId(terminal.id);
terminal.claudeSessionId = undefined;
@@ -570,7 +627,7 @@ export async function invokeClaudeAsync(
{ mode: 0o600 }
);
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile });
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }, extraFlags);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (temp file method, history-safe)');
terminal.pty.write(command);
profileManager.markProfileUsed(activeProfile.id);
@@ -579,7 +636,7 @@ export async function invokeClaudeAsync(
return;
} else if (activeProfile.configDir) {
const escapedConfigDir = escapeShellArg(activeProfile.configDir);
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir });
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }, extraFlags);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (configDir method, history-safe)');
terminal.pty.write(command);
profileManager.markProfileUsed(activeProfile.id);
@@ -595,7 +652,7 @@ export async function invokeClaudeAsync(
debugLog('[ClaudeIntegration:invokeClaudeAsync] Using terminal environment for non-default profile:', activeProfile.name);
}
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' });
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (default method):', command);
terminal.pty.write(command);
@@ -760,7 +817,7 @@ export async function switchClaudeProfile(
terminal: TerminalProcess,
profileId: string,
getWindow: WindowGetter,
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => Promise<void>,
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string, dangerouslySkipPermissions?: boolean) => Promise<void>,
clearRateLimitCallback: (terminalId: string) => void
): Promise<{ success: boolean; error?: string }> {
// Always-on tracing
@@ -841,13 +898,15 @@ export async function switchClaudeProfile(
clearRateLimitCallback(terminal.id);
const projectPath = terminal.projectPath || terminal.cwd;
console.warn('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with profile:', profileId, '| cwd:', projectPath);
console.warn('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with profile:', profileId, '| cwd:', projectPath, '| YOLO:', terminal.dangerouslySkipPermissions);
debugLog('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with new profile:', {
terminalId: terminal.id,
projectPath,
profileId
profileId,
dangerouslySkipPermissions: terminal.dangerouslySkipPermissions
});
await invokeClaudeCallback(terminal.id, projectPath, profileId);
// Pass the stored dangerouslySkipPermissions value to preserve YOLO mode across profile switches
await invokeClaudeCallback(terminal.id, projectPath, profileId, terminal.dangerouslySkipPermissions);
debugLog('[ClaudeIntegration:switchClaudeProfile] Setting active profile in profile manager');
profileManager.setActiveProfile(profileId);
@@ -159,3 +159,90 @@ export function detectClaudeBusyState(data: string): 'busy' | 'idle' | null {
}
return null;
}
/**
* Patterns indicating Claude Code has exited and returned to shell
*
* These patterns detect shell prompts that are distinct from Claude's simple ">" prompt.
* Shell prompts typically include:
* - Username and hostname (user@host)
* - Current directory
* - Git branch indicators
* - Shell-specific characters at the end ($, %, #, )
*
* We look for these patterns to distinguish between Claude's idle prompt (">")
* and a proper shell prompt indicating Claude has exited.
*/
const CLAUDE_EXIT_PATTERNS = [
// Standard shell prompts with path/context (bash/zsh)
// Matches: "user@hostname:~/path$", "hostname:path %", "[user@host path]$"
// Must be at line start to avoid matching user@host in Claude's output
// Requires path indicator after colon to avoid matching emails like "user@example.com:"
/^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:[~/$]/m, // user@hostname:~ or user@hostname:/path
// Path-based prompts (often in zsh, fish, etc.)
// Matches: "~/projects $", "/home/user %"
// Anchored to line start to avoid matching paths in Claude's explanations
/^[~/][^\s]*\s*[$%#]\s*$/m,
// Prompts with brackets (common in bash)
// Matches: "[user@host directory]$", "(venv) user@host:~$"
// Anchored to avoid matching array access like ${arr[0]}
/^\s*\[[^\]]+\]\s*[$%#]\s*$/m,
// Virtual environment or conda prompts followed by standard prompt
// Matches: "(venv) $", "(base) user@host:~$"
/^\([a-zA-Z0-9_-]+\)\s*.*[$%#]\s*$/m,
// Starship, Oh My Zsh, Powerlevel10k common patterns
// Matches: "", "➜", "λ" at end of line (often colored/styled)
// Anchored to avoid matching Unicode arrows in Claude's explanations
/^\s*[❯➜λ]\s*$/m,
// Fish shell prompt patterns
// Matches: "user@host ~/path>", "~/path>"
// Anchored to avoid matching file paths ending with >
/^~?\/[^\s]*>\s*$/m,
// Git branch in prompt followed by prompt character
// Matches: "(main) $", "[git:main] >"
// Anchored to avoid matching code snippets with brackets
/^\s*[([a-zA-Z0-9/_-]+[)\]]\s*[$%#>]\s*$/m,
// Simple but distinctive shell prompts with hostname
// Matches: "hostname$", "hostname %"
/^[a-zA-Z0-9._-]+[$%#]\s*$/m,
// Detect Claude exit messages (optional, catches explicit exits)
/Goodbye!?\s*$/im,
/Session ended/i,
/Exiting Claude/i,
];
/**
* Check if output indicates Claude has exited and returned to shell
*
* This is more specific than shell prompt detection - it looks for patterns
* that indicate we've returned to a shell AFTER being in Claude mode.
*/
export function isClaudeExitOutput(data: string): boolean {
return CLAUDE_EXIT_PATTERNS.some(pattern => pattern.test(data));
}
/**
* Detect if Claude has exited based on terminal output
* Returns true if output indicates Claude has exited and shell is ready
*
* This function should be called when the terminal is in Claude mode
* to detect if Claude has exited (user typed /exit, Ctrl+D, etc.)
*/
export function detectClaudeExit(data: string): boolean {
// First, make sure this doesn't look like Claude activity
// If we see Claude busy indicators, Claude hasn't exited
if (isClaudeBusyOutput(data)) {
return false;
}
// Check for Claude exit patterns (shell prompt return)
return isClaudeExitOutput(data);
}
@@ -7,9 +7,14 @@
import * as net from 'net';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { spawn, ChildProcess } from 'child_process';
import { app } from 'electron';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SOCKET_PATH =
process.platform === 'win32'
? `\\\\.\\pipe\\auto-claude-pty-${process.getuid?.() || 'default'}`
@@ -16,6 +16,7 @@ export interface EventHandlerCallbacks {
onRateLimit: (terminal: TerminalProcess, data: string) => void;
onOAuthToken: (terminal: TerminalProcess, data: string) => void;
onClaudeBusyChange: (terminal: TerminalProcess, isBusy: boolean) => void;
onClaudeExit: (terminal: TerminalProcess) => void;
}
// Track the last known busy state per terminal to avoid duplicate events
@@ -58,6 +59,14 @@ export function handleTerminalData(
callbacks.onClaudeBusyChange(terminal, isBusy);
}
}
// Detect Claude exit (returned to shell prompt)
// Only check if not busy - busy output takes precedence
if (busyState !== 'busy' && OutputParser.detectClaudeExit(data)) {
callbacks.onClaudeExit(terminal);
// Clear busy state tracking since Claude has exited
lastBusyState.delete(terminal.id);
}
}
}
@@ -97,6 +106,9 @@ export function createEventCallbacks(
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, terminal.id, isBusy);
}
},
onClaudeExit: (terminal) => {
ClaudeIntegration.handleClaudeExit(terminal, getWindow);
}
};
}
@@ -189,6 +189,9 @@ export async function restoreTerminal(
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, session.id, session.title);
// Always sync worktreeConfig to renderer (even if undefined) to ensure correct state
// This handles both: showing labels after recovery AND clearing stale labels when worktrees are deleted
win.webContents.send(IPC_CHANNELS.TERMINAL_WORKTREE_CONFIG_CHANGE, session.id, terminal.worktreeConfig);
}
// Defer Claude resume until terminal becomes active (is viewed by user)
@@ -145,7 +145,7 @@ export class TerminalManager {
/**
* Invoke Claude in a terminal with optional profile override (async - non-blocking)
*/
async invokeClaudeAsync(id: string, cwd?: string, profileId?: string): Promise<void> {
async invokeClaudeAsync(id: string, cwd?: string, profileId?: string, dangerouslySkipPermissions?: boolean): Promise<void> {
const terminal = this.terminals.get(id);
if (!terminal) {
return;
@@ -164,7 +164,8 @@ export class TerminalManager {
this.terminals,
this.getWindow
);
}
},
dangerouslySkipPermissions
);
}
@@ -172,7 +173,7 @@ export class TerminalManager {
* Invoke Claude in a terminal with optional profile override
* @deprecated Use invokeClaudeAsync for non-blocking behavior
*/
invokeClaude(id: string, cwd?: string, profileId?: string): void {
invokeClaude(id: string, cwd?: string, profileId?: string, dangerouslySkipPermissions?: boolean): void {
const terminal = this.terminals.get(id);
if (!terminal) {
return;
@@ -191,7 +192,8 @@ export class TerminalManager {
this.terminals,
this.getWindow
);
}
},
dangerouslySkipPermissions
);
}
@@ -208,7 +210,7 @@ export class TerminalManager {
terminal,
profileId,
this.getWindow,
async (terminalId, cwd, profileId) => this.invokeClaudeAsync(terminalId, cwd, profileId),
async (terminalId, cwd, profileId, dangerouslySkipPermissions) => this.invokeClaudeAsync(terminalId, cwd, profileId, dangerouslySkipPermissions),
(terminalId) => this.lastNotifiedRateLimitReset.delete(terminalId)
);
}
+2
View File
@@ -19,6 +19,8 @@ export interface TerminalProcess {
worktreeConfig?: TerminalWorktreeConfig;
/** Whether this terminal has a pending Claude resume that should be triggered on activation */
pendingClaudeResume?: boolean;
/** Whether Claude was invoked with --dangerously-skip-permissions (YOLO mode) */
dangerouslySkipPermissions?: boolean;
}
/**
@@ -1,7 +1,12 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { existsSync, readFileSync } from 'fs';
import { spawn } from 'child_process';
import { app } from 'electron';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { EventEmitter } from 'events';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
import { parsePythonCommand, getValidatedPythonPath } from './python-detector';
@@ -19,6 +19,7 @@ export interface AppUpdateAPI {
downloadStableUpdate: () => Promise<IPCResult>;
installAppUpdate: () => void;
getAppVersion: () => Promise<string>;
getDownloadedAppUpdate: () => Promise<IPCResult<AppUpdateInfo | null>>;
// Event Listeners
onAppUpdateAvailable: (
@@ -56,6 +57,9 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({
getAppVersion: (): Promise<string> =>
invokeIpc(IPC_CHANNELS.APP_UPDATE_GET_VERSION),
getDownloadedAppUpdate: (): Promise<IPCResult<AppUpdateInfo | null>> =>
invokeIpc(IPC_CHANNELS.APP_UPDATE_GET_DOWNLOADED),
// Event Listeners
onAppUpdateAvailable: (
callback: (info: AppUpdateAvailableEvent) => void
@@ -4,10 +4,12 @@
* Provides access to Claude Code CLI management:
* - Check installed vs latest version
* - Install or update Claude Code
* - Get available versions for rollback
* - Install specific version
*/
import { IPC_CHANNELS } from '../../../shared/constants';
import type { ClaudeCodeVersionInfo } from '../../../shared/types/cli';
import type { ClaudeCodeVersionInfo, ClaudeCodeVersionList, ClaudeInstallationList } from '../../../shared/types/cli';
import { invokeIpc } from './ipc-utils';
/**
@@ -30,6 +32,47 @@ export interface ClaudeCodeVersionResult {
error?: string;
}
/**
* Result of fetching available versions
*/
export interface ClaudeCodeVersionsResult {
success: boolean;
data?: ClaudeCodeVersionList;
error?: string;
}
/**
* Result of installing a specific version
*/
export interface ClaudeCodeInstallVersionResult {
success: boolean;
data?: {
command: string;
version: string;
};
error?: string;
}
/**
* Result of getting installations
*/
export interface ClaudeCodeInstallationsResult {
success: boolean;
data?: ClaudeInstallationList;
error?: string;
}
/**
* Result of setting active path
*/
export interface ClaudeCodeSetActivePathResult {
success: boolean;
data?: {
path: string;
};
error?: string;
}
/**
* Claude Code API interface exposed to renderer
*/
@@ -45,6 +88,30 @@ export interface ClaudeCodeAPI {
* Opens the user's terminal with the install command
*/
installClaudeCode: () => Promise<ClaudeCodeInstallResult>;
/**
* Get available Claude Code CLI versions
* Returns list of versions sorted newest first
*/
getClaudeCodeVersions: () => Promise<ClaudeCodeVersionsResult>;
/**
* Install a specific version of Claude Code CLI
* Opens the user's terminal with the install command for the specified version
*/
installClaudeCodeVersion: (version: string) => Promise<ClaudeCodeInstallVersionResult>;
/**
* Get all Claude CLI installations found on the system
* Returns list of installations with paths, versions, and sources
*/
getClaudeCodeInstallations: () => Promise<ClaudeCodeInstallationsResult>;
/**
* Set the active Claude CLI path
* Updates settings and CLI tool manager cache
*/
setClaudeCodeActivePath: (cliPath: string) => Promise<ClaudeCodeSetActivePathResult>;
}
/**
@@ -55,5 +122,17 @@ export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION),
installClaudeCode: (): Promise<ClaudeCodeInstallResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL)
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL),
getClaudeCodeVersions: (): Promise<ClaudeCodeVersionsResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_GET_VERSIONS),
installClaudeCodeVersion: (version: string): Promise<ClaudeCodeInstallVersionResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL_VERSION, version),
getClaudeCodeInstallations: (): Promise<ClaudeCodeInstallationsResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_GET_INSTALLATIONS),
setClaudeCodeActivePath: (cliPath: string): Promise<ClaudeCodeSetActivePathResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_SET_ACTIVE_PATH, cliPath),
});
+7 -4
View File
@@ -13,7 +13,8 @@ import type {
SupportedIDE,
SupportedTerminal,
WorktreeCreatePROptions,
WorktreeCreatePRResult
WorktreeCreatePRResult,
ImageAttachment
} from '../../shared/types';
export interface TaskAPI {
@@ -35,7 +36,8 @@ export interface TaskAPI {
submitReview: (
taskId: string,
approved: boolean,
feedback?: string
feedback?: string,
images?: ImageAttachment[]
) => Promise<IPCResult>;
updateTaskStatus: (
taskId: string,
@@ -112,9 +114,10 @@ export const createTaskAPI = (): TaskAPI => ({
submitReview: (
taskId: string,
approved: boolean,
feedback?: string
feedback?: string,
images?: ImageAttachment[]
): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_REVIEW, taskId, approved, feedback),
ipcRenderer.invoke(IPC_CHANNELS.TASK_REVIEW, taskId, approved, feedback, images),
updateTaskStatus: (
taskId: string,
@@ -69,6 +69,7 @@ export interface TerminalAPI {
onTerminalOutput: (callback: (id: string, data: string) => void) => () => void;
onTerminalExit: (callback: (id: string, exitCode: number) => void) => () => void;
onTerminalTitleChange: (callback: (id: string, title: string) => void) => () => void;
onTerminalWorktreeConfigChange: (callback: (id: string, config: TerminalWorktreeConfig | undefined) => void) => () => void;
onTerminalClaudeSession: (callback: (id: string, sessionId: string) => void) => () => void;
onTerminalRateLimit: (callback: (info: RateLimitInfo) => void) => () => void;
onTerminalOAuthToken: (
@@ -78,6 +79,7 @@ export interface TerminalAPI {
callback: (info: { terminalId: string; profileId: string; profileName: string }) => void
) => () => void;
onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void;
onTerminalClaudeExit: (callback: (id: string) => void) => () => void;
onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void;
// Claude Profile Management
@@ -227,6 +229,22 @@ export const createTerminalAPI = (): TerminalAPI => ({
};
},
onTerminalWorktreeConfigChange: (
callback: (id: string, config: TerminalWorktreeConfig | undefined) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
id: string,
config: TerminalWorktreeConfig | undefined
): void => {
callback(id, config);
};
ipcRenderer.on(IPC_CHANNELS.TERMINAL_WORKTREE_CONFIG_CHANGE, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_WORKTREE_CONFIG_CHANGE, handler);
};
},
onTerminalClaudeSession: (
callback: (id: string, sessionId: string) => void
): (() => void) => {
@@ -304,6 +322,21 @@ export const createTerminalAPI = (): TerminalAPI => ({
};
},
onTerminalClaudeExit: (
callback: (id: string) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
id: string
): void => {
callback(id);
};
ipcRenderer.on(IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, handler);
};
},
onTerminalPendingResume: (
callback: (id: string, sessionId?: string) => void
): (() => void) => {
@@ -9,6 +9,7 @@ import {
Download,
RefreshCw,
ExternalLink,
FolderOpen,
} from "lucide-react";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
@@ -23,8 +24,15 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { cn } from "../lib/utils";
import type { ClaudeCodeVersionInfo } from "../../shared/types/cli";
import type { ClaudeCodeVersionInfo, ClaudeInstallationInfo } from "../../shared/types/cli";
interface ClaudeCodeStatusBadgeProps {
className?: string;
@@ -34,6 +42,8 @@ type StatusType = "loading" | "installed" | "outdated" | "not-found" | "error";
// Check every 24 hours
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
// Delay before re-checking version after install/update
const VERSION_RECHECK_DELAY_MS = 5000;
/**
* Claude Code CLI status badge for the sidebar.
@@ -48,6 +58,21 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
const [isOpen, setIsOpen] = useState(false);
const [showUpdateWarning, setShowUpdateWarning] = useState(false);
// Version rollback state
const [availableVersions, setAvailableVersions] = useState<string[]>([]);
const [isLoadingVersions, setIsLoadingVersions] = useState(false);
const [versionsError, setVersionsError] = useState<string | null>(null);
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [showRollbackWarning, setShowRollbackWarning] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
// CLI path selection state
const [installations, setInstallations] = useState<ClaudeInstallationInfo[]>([]);
const [isLoadingInstallations, setIsLoadingInstallations] = useState(false);
const [installationsError, setInstallationsError] = useState<string | null>(null);
const [selectedInstallation, setSelectedInstallation] = useState<string | null>(null);
const [showPathChangeWarning, setShowPathChangeWarning] = useState(false);
// Check Claude Code version
const checkVersion = useCallback(async () => {
try {
@@ -78,6 +103,54 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
}
}, []);
// Fetch available versions
const fetchVersions = useCallback(async () => {
if (!window.electronAPI?.getClaudeCodeVersions) {
return;
}
setIsLoadingVersions(true);
setVersionsError(null);
try {
const result = await window.electronAPI.getClaudeCodeVersions();
if (result.success && result.data) {
setAvailableVersions(result.data.versions);
} else {
setVersionsError(result.error || "Failed to load versions");
}
} catch (err) {
console.error("Failed to fetch versions:", err);
setVersionsError("Failed to load versions");
} finally {
setIsLoadingVersions(false);
}
}, []);
// Fetch CLI installations
const fetchInstallations = useCallback(async () => {
if (!window.electronAPI?.getClaudeCodeInstallations) {
return;
}
setIsLoadingInstallations(true);
setInstallationsError(null);
try {
const result = await window.electronAPI.getClaudeCodeInstallations();
if (result.success && result.data) {
setInstallations(result.data.installations);
} else {
setInstallationsError(result.error || "Failed to load installations");
}
} catch (err) {
console.error("Failed to fetch installations:", err);
setInstallationsError("Failed to load installations");
} finally {
setIsLoadingInstallations(false);
}
}, []);
// Initial check and periodic re-check
useEffect(() => {
checkVersion();
@@ -90,12 +163,28 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
return () => clearInterval(interval);
}, [checkVersion]);
// Fetch versions when popover opens and Claude is installed
useEffect(() => {
if (isOpen && versionInfo?.installed && availableVersions.length === 0) {
fetchVersions();
}
}, [isOpen, versionInfo?.installed, availableVersions.length, fetchVersions]);
// Fetch installations when popover opens
useEffect(() => {
if (isOpen && installations.length === 0) {
fetchInstallations();
}
}, [isOpen, installations.length, fetchInstallations]);
// Perform the actual install/update
const performInstall = async () => {
setIsInstalling(true);
setShowUpdateWarning(false);
setInstallError(null);
try {
if (!window.electronAPI?.installClaudeCode) {
setInstallError("Installation not available");
return;
}
@@ -105,15 +194,85 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
// Re-check after a delay
setTimeout(() => {
checkVersion();
}, 5000);
}, VERSION_RECHECK_DELAY_MS);
} else {
setInstallError(result.error || "Installation failed");
}
} catch (err) {
console.error("Failed to install Claude Code:", err);
setInstallError(err instanceof Error ? err.message : "Installation failed");
} finally {
setIsInstalling(false);
}
};
// Perform version rollback/switch
const performVersionSwitch = async () => {
if (!selectedVersion) return;
setIsInstalling(true);
setShowRollbackWarning(false);
setInstallError(null);
try {
if (!window.electronAPI?.installClaudeCodeVersion) {
setInstallError("Version switching not available");
return;
}
const result = await window.electronAPI.installClaudeCodeVersion(selectedVersion);
if (result.success) {
// Re-check after a delay
setTimeout(() => {
checkVersion();
}, VERSION_RECHECK_DELAY_MS);
} else {
setInstallError(result.error || "Failed to switch version");
}
} catch (err) {
console.error("Failed to switch Claude Code version:", err);
setInstallError(err instanceof Error ? err.message : "Failed to switch version");
} finally {
setIsInstalling(false);
setSelectedVersion(null);
}
};
// Perform CLI path switch
const performPathSwitch = async () => {
if (!selectedInstallation) return;
setIsInstalling(true);
setShowPathChangeWarning(false);
setInstallError(null);
try {
if (!window.electronAPI?.setClaudeCodeActivePath) {
setInstallError("Path switching not available");
return;
}
const result = await window.electronAPI.setClaudeCodeActivePath(selectedInstallation);
if (result.success) {
// Re-check version and refresh installations
setTimeout(() => {
checkVersion();
fetchInstallations();
}, VERSION_RECHECK_DELAY_MS);
} else {
setInstallError(result.error || "Failed to switch CLI path");
}
} catch (err) {
console.error("Failed to switch Claude CLI path:", err);
setInstallError(err instanceof Error ? err.message : "Failed to switch CLI path");
} finally {
setIsInstalling(false);
setSelectedInstallation(null);
}
};
// Handle install/update button click
const handleInstall = () => {
if (status === "outdated") {
@@ -125,6 +284,34 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
}
};
// Handle installation selection
const handleInstallationSelect = (cliPath: string) => {
// Don't do anything if it's the currently active installation
const installation = installations.find(i => i.path === cliPath);
if (installation?.isActive) {
return;
}
setInstallError(null);
setSelectedInstallation(cliPath);
setShowPathChangeWarning(true);
};
// Normalize version string by removing 'v' prefix for comparison
const normalizeVersion = (v: string) => v.replace(/^v/, '');
// Handle version selection
const handleVersionSelect = (version: string) => {
// Don't do anything if it's the currently installed version (normalize both for comparison)
const normalizedSelected = normalizeVersion(version);
const normalizedInstalled = versionInfo?.installed ? normalizeVersion(versionInfo.installed) : '';
if (normalizedSelected === normalizedInstalled) {
return;
}
setInstallError(null);
setSelectedVersion(version);
setShowRollbackWarning(true);
};
// Get status indicator color
const getStatusColor = () => {
switch (status) {
@@ -252,6 +439,20 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
<span className="font-mono">{versionInfo.latest}</span>
</div>
)}
{versionInfo.path && (
<div className="flex justify-between items-center gap-2">
<span className="text-muted-foreground flex items-center gap-1">
<FolderOpen className="h-3 w-3" />
{t("navigation:claudeCode.path", "Path")}:
</span>
<span
className="font-mono text-[10px] truncate max-w-[140px]"
title={versionInfo.path}
>
{versionInfo.path}
</span>
</div>
)}
{lastChecked && (
<div className="flex justify-between text-muted-foreground">
<span>{t("navigation:claudeCode.lastChecked", "Last checked")}:</span>
@@ -292,6 +493,107 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
</Button>
</div>
{/* Install/Update error display */}
{installError && (
<div className="text-xs p-2 bg-destructive/10 text-destructive rounded-md flex items-center gap-2">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>{installError}</span>
</div>
)}
{/* Version selector - only show when Claude is installed */}
{versionInfo?.installed && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
{t("navigation:claudeCode.switchVersion", "Switch Version")}
</label>
<Select
value={selectedVersion || ""}
onValueChange={handleVersionSelect}
disabled={isLoadingVersions || isInstalling}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue
placeholder={
isLoadingVersions
? t("navigation:claudeCode.loadingVersions", "Loading versions...")
: versionsError
? t("navigation:claudeCode.failedToLoadVersions", "Failed to load versions")
: t("navigation:claudeCode.selectVersion", "Select version")
}
/>
</SelectTrigger>
<SelectContent>
{availableVersions.map((version) => {
const isCurrentVersion = normalizeVersion(version) === normalizeVersion(versionInfo.installed || '');
return (
<SelectItem
key={version}
value={version}
className="text-xs"
disabled={isCurrentVersion}
>
<span className="font-mono">{version}</span>
{isCurrentVersion && (
<span className="ml-2 text-muted-foreground">
({t("navigation:claudeCode.currentVersion", "Current")})
</span>
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
{/* CLI Installation selector - show when multiple installations are found */}
{installations.length > 1 && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
{t("navigation:claudeCode.switchInstallation", "Switch Installation")}
</label>
<Select
value={selectedInstallation || ""}
onValueChange={handleInstallationSelect}
disabled={isLoadingInstallations || isInstalling}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue
placeholder={
isLoadingInstallations
? t("navigation:claudeCode.loadingInstallations", "Loading installations...")
: installationsError
? t("navigation:claudeCode.failedToLoadInstallations", "Failed to load installations")
: t("navigation:claudeCode.selectInstallation", "Select installation")
}
/>
</SelectTrigger>
<SelectContent>
{installations.map((installation) => (
<SelectItem
key={installation.path}
value={installation.path}
className="text-xs"
disabled={installation.isActive}
>
<div className="flex flex-col">
<span className="font-mono text-[10px] truncate max-w-[180px]" title={installation.path}>
{/* Split on both path separators for cross-platform compatibility */}
{installation.path.split(/[/\\]/).slice(-2).join('/') || installation.path}
</span>
<span className="text-muted-foreground text-[9px]">
{installation.version ? `v${installation.version}` : t("navigation:claudeCode.versionUnknown", "version unknown")} ({installation.source})
{installation.isActive && ` - ${t("navigation:claudeCode.activeInstallation", "Active")}`}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Learn more link */}
<Button
variant="link"
@@ -350,6 +652,61 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Version rollback warning dialog */}
<AlertDialog open={showRollbackWarning} onOpenChange={setShowRollbackWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("navigation:claudeCode.rollbackWarningTitle", "Switch to version {{version}}?", {
version: selectedVersion,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"navigation:claudeCode.rollbackWarningDescription",
"Switching versions will close all running Claude Code sessions. Any unsaved work in those sessions may be lost. Make sure to save your work before proceeding."
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setSelectedVersion(null)}>
{t("common:cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={performVersionSwitch}>
{t("navigation:claudeCode.switchAnyway", "Switch Anyway")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Path change warning dialog */}
<AlertDialog open={showPathChangeWarning} onOpenChange={setShowPathChangeWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("navigation:claudeCode.pathChangeWarningTitle", "Switch CLI installation?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"navigation:claudeCode.pathChangeWarningDescription",
"Switching CLI installations will use a different Claude Code binary. Any running sessions will continue using the previous installation until restarted."
)}
<span className="block mt-2 font-mono text-xs break-all">
{selectedInstallation}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setSelectedInstallation(null)}>
{t("common:cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={performPathSwitch}>
{t("navigation:claudeCode.switchInstallationConfirm", "Switch")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Popover>
);
}
@@ -12,7 +12,7 @@
*/
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, ChevronDown, ChevronUp, RotateCcw, FolderTree, GitBranch } from 'lucide-react';
import { Loader2, ChevronDown, ChevronUp, RotateCcw, FolderTree, GitBranch, Info } from 'lucide-react';
import { Button } from './ui/button';
import { Label } from './ui/label';
import {
@@ -521,6 +521,19 @@ export function TaskCreationWizard({
}
>
<div className="space-y-6">
{/* Worktree isolation info banner */}
<div className="flex items-start gap-3 p-4 bg-info/10 border border-info/30 rounded-lg">
<Info className="h-5 w-5 text-info flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-foreground mb-1">
{t('tasks:wizard.worktreeNotice.title')}
</h4>
<p className="text-sm text-muted-foreground">
{t('tasks:wizard.worktreeNotice.description')}
</p>
</div>
</div>
{/* Main form fields */}
<TaskFormFields
description={description}
@@ -12,7 +12,8 @@ import {
PointerSensor,
KeyboardSensor,
useSensor,
useSensors
useSensors,
closestCenter,
} from '@dnd-kit/core';
import {
SortableContext,
@@ -292,17 +293,22 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }:
// Handle file drop on terminal
const overId = over.id.toString();
if (overId.startsWith('terminal-')) {
const terminalId = overId.replace('terminal-', '');
let terminalId: string | null = null;
if (activeData?.path) {
// Quote the path if it contains spaces
const quotedPath = activeData.path.includes(' ') ? `"${activeData.path}"` : activeData.path;
// Insert the file path into the terminal with a trailing space
window.electronAPI.sendTerminalInput(terminalId, quotedPath + ' ');
}
if (overId.startsWith('terminal-')) {
terminalId = overId.replace('terminal-', '');
} else if (terminals.some(t => t.id === overId)) {
// closestCenter might return the sortable ID instead of droppable ID
terminalId = overId;
}
}, [reorderTerminals]);
if (terminalId && activeData?.path) {
// Quote the path if it contains spaces
const quotedPath = activeData.path.includes(' ') ? `"${activeData.path}"` : activeData.path;
// Insert the file path into the terminal with a trailing space
window.electronAPI.sendTerminalInput(terminalId, quotedPath + ' ');
}
}, [reorderTerminals, terminals]);
// Calculate grid layout based on number of terminals
const gridLayout = useMemo(() => {
@@ -358,6 +364,7 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }:
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
@@ -178,9 +178,10 @@ export function useGitHubPRs(
}
// Batch preload review results for PRs not in store (single IPC call)
// Skip PRs that are currently being reviewed - their state is managed by IPC listeners
const prsNeedingPreload = result.filter((pr) => {
const existingState = getPRReviewState(projectId, pr.number);
return !existingState?.result;
return !existingState?.result && !existingState?.isReviewing;
});
if (prsNeedingPreload.length > 0) {
@@ -291,8 +292,9 @@ export function useGitHubPRs(
// Load existing review from disk if not already in store
const existingState = getPRReviewState(projectId, prNumber);
// Only fetch from disk if we don't have a result in the store
if (!existingState?.result) {
// Only fetch from disk if we don't have a result in the store AND no review is running
// If a review is in progress, the state is managed by IPC listeners - don't overwrite it
if (!existingState?.result && !existingState?.isReviewing) {
window.electronAPI.github.getPRReview(projectId, prNumber).then((result) => {
if (result) {
// Update store with the loaded result
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { ChevronRight, ExternalLink, Lightbulb, Play, X } from 'lucide-react';
import { ChevronRight, ExternalLink, Lightbulb, Loader2, Play, X } from 'lucide-react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import {
@@ -30,9 +30,10 @@ interface IdeaDetailPanelProps {
onConvert: (idea: Idea) => void;
onGoToTask?: (taskId: string) => void;
onDismiss: (idea: Idea) => void;
isConverting?: boolean;
}
export function IdeaDetailPanel({ idea, onClose, onConvert, onGoToTask, onDismiss }: IdeaDetailPanelProps) {
export function IdeaDetailPanel({ idea, onClose, onConvert, onGoToTask, onDismiss, isConverting }: IdeaDetailPanelProps) {
const { t } = useTranslation('common');
const isDismissed = idea.status === 'dismissed';
const isConverted = idea.status === 'converted';
@@ -66,7 +67,7 @@ export function IdeaDetailPanel({ idea, onClose, onConvert, onGoToTask, onDismis
<div className="flex-1 overflow-auto p-4 space-y-6">
{/* Description */}
<div>
<h3 className="text-sm font-medium mb-2">Description</h3>
<h3 className="text-sm font-medium mb-2">{t('common:ideation.description')}</h3>
<p className="text-sm text-muted-foreground">{idea.description}</p>
</div>
@@ -74,7 +75,7 @@ export function IdeaDetailPanel({ idea, onClose, onConvert, onGoToTask, onDismis
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
<Lightbulb className="h-4 w-4" />
Rationale
{t('common:ideation.rationale')}
</h3>
<p className="text-sm text-muted-foreground">{idea.rationale}</p>
</div>
@@ -91,9 +92,13 @@ export function IdeaDetailPanel({ idea, onClose, onConvert, onGoToTask, onDismis
{/* Actions */}
{!isDismissed && !isConverted && (
<div className="shrink-0 p-4 border-t border-border space-y-2">
<Button className="w-full" onClick={() => onConvert(idea)}>
<Play className="h-4 w-4 mr-2" />
Convert to Auto-Build Task
<Button className="w-full" onClick={() => onConvert(idea)} disabled={isConverting}>
{isConverting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Play className="h-4 w-4 mr-2" />
)}
{isConverting ? t('common:ideation.converting') : t('common:ideation.convertToTask')}
</Button>
<Button
variant="outline"
@@ -104,7 +109,7 @@ export function IdeaDetailPanel({ idea, onClose, onConvert, onGoToTask, onDismis
}}
>
<X className="h-4 w-4 mr-2" />
Dismiss Idea
{t('common:ideation.dismissIdea')}
</Button>
</div>
)}
@@ -112,7 +117,7 @@ export function IdeaDetailPanel({ idea, onClose, onConvert, onGoToTask, onDismis
<div className="shrink-0 p-4 border-t border-border">
<Button className="w-full" onClick={() => onGoToTask(idea.taskId!)}>
<ExternalLink className="h-4 w-4 mr-2" />
Go to Task
{t('common:ideation.goToTask')}
</Button>
</div>
)}
@@ -41,6 +41,7 @@ export function Ideation({ projectId, onGoToTask }: IdeationProps) {
summary,
activeIdeas,
selectedIds,
convertingIdeas,
setSelectedIdea,
setActiveTab,
setShowConfigDialog,
@@ -217,6 +218,7 @@ export function Ideation({ projectId, onGoToTask }: IdeationProps) {
onConvert={handleConvertToTask}
onGoToTask={handleGoToTask}
onDismiss={handleDismiss}
isConverting={convertingIdeas.has(selectedIdea.id)}
/>
)}
@@ -1,4 +1,6 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from '../../../hooks/use-toast';
import {
useIdeationStore,
loadIdeation,
@@ -27,6 +29,7 @@ interface UseIdeationOptions {
export function useIdeation(projectId: string, options: UseIdeationOptions = {}) {
const { onGoToTask, showArchived: externalShowArchived } = options;
const { t } = useTranslation('common');
const session = useIdeationStore((state) => state.session);
const generationStatus = useIdeationStore((state) => state.generationStatus);
const isGenerating = useIdeationStore((state) => state.isGenerating);
@@ -48,6 +51,9 @@ export function useIdeation(projectId: string, options: UseIdeationOptions = {})
const [pendingAction, setPendingAction] = useState<'generate' | 'refresh' | 'append' | null>(null);
const [showAddMoreDialog, setShowAddMoreDialog] = useState(false);
const [typesToAdd, setTypesToAdd] = useState<IdeationType[]>([]);
const [convertingIdeas, setConvertingIdeas] = useState<Set<string>>(new Set());
// Ref for synchronous tracking - prevents race condition from stale React state closure
const convertingIdeaRef = useRef<Set<string>>(new Set());
const { hasToken, isLoading: isCheckingToken, checkAuth } = useIdeationAuth();
@@ -130,11 +136,42 @@ export function useIdeation(projectId: string, options: UseIdeationOptions = {})
};
const handleConvertToTask = async (idea: Idea) => {
const result = await window.electronAPI.convertIdeaToTask(projectId, idea.id);
if (result.success && result.data) {
// Store the taskId on the idea so we can navigate to it later
useIdeationStore.getState().setIdeaTaskId(idea.id, result.data.id);
loadTasks(projectId);
// Guard: use ref for synchronous check to prevent race condition from stale state closure
// React state is captured at render time, so rapid clicks would both see empty set
if (convertingIdeaRef.current.has(idea.id)) {
return;
}
// Mark as converting - update ref synchronously first, then state for UI
convertingIdeaRef.current.add(idea.id);
setConvertingIdeas(new Set(convertingIdeaRef.current));
try {
const result = await window.electronAPI.convertIdeaToTask(projectId, idea.id);
if (result.success && result.data) {
// Store the taskId on the idea so we can navigate to it later
useIdeationStore.getState().setIdeaTaskId(idea.id, result.data.id);
loadTasks(projectId);
} else {
// Show error toast when conversion fails (e.g., already converted, idea not found)
toast({
variant: 'destructive',
title: t('ideation.conversionFailed'),
description: result.error || t('ideation.conversionFailedDescription')
});
}
} catch (error) {
// Handle unexpected errors (network issues, etc.)
console.error('Failed to convert idea to task:', error);
toast({
variant: 'destructive',
title: t('ideation.conversionError'),
description: t('ideation.conversionErrorDescription')
});
} finally {
// Always clear converting state - update ref first, then state
convertingIdeaRef.current.delete(idea.id);
setConvertingIdeas(new Set(convertingIdeaRef.current));
}
};
@@ -228,6 +265,7 @@ export function useIdeation(projectId: string, options: UseIdeationOptions = {})
activeIdeas,
archivedIdeas,
selectedIds,
convertingIdeas,
// Actions
setSelectedIdea,
@@ -80,11 +80,60 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
// Stable downgrade state (shown when user turns off beta while on prerelease)
const [stableDowngradeInfo, setStableDowngradeInfo] = useState<AppUpdateInfo | null>(null);
// Check for updates on mount
// Check for updates on mount, including any already-downloaded updates
useEffect(() => {
if (section === 'updates') {
checkForAppUpdates();
if (section !== 'updates') {
return;
}
let isCancelled = false;
// First check if an update was already downloaded, then check for new updates
(async () => {
// Check if an update was already downloaded (e.g., auto-downloaded in background)
try {
const result = await window.electronAPI.getDownloadedAppUpdate();
// Skip state updates if component unmounted or section changed
if (isCancelled) return;
if (result.success && result.data) {
// An update was already downloaded - show "Install and Restart" button
setAppUpdateInfo(result.data);
setIsAppUpdateDownloaded(true);
console.log('[AdvancedSettings] Found already-downloaded update:', result.data.version);
return; // Don't check for new updates if we already have one downloaded
}
} catch (err) {
console.error('Failed to check for downloaded update:', err);
if (isCancelled) return;
}
// Only check for available updates if no update is already downloaded
// (electron-updater reports no available update when one is already downloaded,
// which would clear our appUpdateInfo and lose the version metadata)
// Inline the update check with cancellation support
setIsCheckingAppUpdate(true);
try {
const result = await window.electronAPI.checkAppUpdate();
if (isCancelled) return;
if (result.success && result.data) {
setAppUpdateInfo(result.data);
} else {
setAppUpdateInfo(null);
}
} catch (err) {
console.error('Failed to check for app updates:', err);
} finally {
if (!isCancelled) {
setIsCheckingAppUpdate(false);
}
}
})();
return () => {
isCancelled = true;
};
}, [section]);
// Listen for app update events
@@ -1,10 +1,11 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Code, Terminal, RefreshCw, Loader2, Check, FolderOpen } from 'lucide-react';
import { Code, Terminal, RefreshCw, Loader2, Check, FolderOpen, AlertTriangle } from 'lucide-react';
import { Label } from '../ui/label';
import { Input } from '../ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { Button } from '../ui/button';
import { Switch } from '../ui/switch';
import { SettingsSection } from './SettingsSection';
import type { AppSettings, SupportedIDE, SupportedTerminal } from '../../../shared/types';
@@ -364,6 +365,37 @@ export function DevToolsSettings({ settings, onSettingsChange }: DevToolsSetting
)}
</div>
{/* YOLO Mode Toggle */}
<div className="space-y-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<Label htmlFor="yolo-mode" className="text-amber-200">
{t('devtools.yoloMode.label', 'YOLO Mode')}
</Label>
</div>
<Switch
id="yolo-mode"
checked={settings.dangerouslySkipPermissions ?? false}
onCheckedChange={(checked) => {
onSettingsChange({
...settings,
dangerouslySkipPermissions: checked
});
}}
/>
</div>
<p className="text-xs text-amber-400/80">
{t('devtools.yoloMode.description', 'Start Claude with --dangerously-skip-permissions flag, bypassing all safety prompts. Use with extreme caution.')}
</p>
{settings.dangerouslySkipPermissions && (
<p className="text-xs text-amber-500 font-medium flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{t('devtools.yoloMode.warning', 'This mode bypasses Claude\'s permission system. Only enable if you fully trust the code being executed.')}
</p>
)}
</div>
{/* Detection Summary */}
{detectedTools && !isDetecting && (
<div className="text-xs text-muted-foreground bg-muted/50 p-3 rounded-md">
@@ -118,13 +118,15 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
};
const handleReject = async () => {
if (!state.feedback.trim()) {
// Allow submission if there's text feedback OR images attached
if (!state.feedback.trim() && state.feedbackImages.length === 0) {
return;
}
state.setIsSubmitting(true);
await submitReview(task.id, false, state.feedback);
await submitReview(task.id, false, state.feedback, state.feedbackImages);
state.setIsSubmitting(false);
state.setFeedback('');
state.setFeedbackImages([]);
};
const handleDelete = async () => {
@@ -516,6 +518,8 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
showConflictDialog={state.showConflictDialog}
onFeedbackChange={state.setFeedback}
onReject={handleReject}
images={state.feedbackImages}
onImagesChange={state.setFeedbackImages}
onMerge={handleMerge}
onDiscard={handleDiscard}
onShowDiscardDialog={state.setShowDiscardDialog}
@@ -1,4 +1,4 @@
import type { Task, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo, WorktreeCreatePRResult } from '../../../shared/types';
import type { Task, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo, ImageAttachment, WorktreeCreatePRResult } from '../../../shared/types';
import {
StagedSuccessMessage,
WorkspaceStatus,
@@ -33,6 +33,10 @@ interface TaskReviewProps {
showConflictDialog: boolean;
onFeedbackChange: (value: string) => void;
onReject: () => void;
/** Image attachments for visual feedback */
images?: ImageAttachment[];
/** Callback when images change */
onImagesChange?: (images: ImageAttachment[]) => void;
onMerge: () => void;
onDiscard: () => void;
onShowDiscardDialog: (show: boolean) => void;
@@ -81,6 +85,8 @@ export function TaskReview({
showConflictDialog,
onFeedbackChange,
onReject,
images,
onImagesChange,
onMerge,
onDiscard,
onShowDiscardDialog,
@@ -157,6 +163,8 @@ export function TaskReview({
isSubmitting={isSubmitting}
onFeedbackChange={onFeedbackChange}
onReject={onReject}
images={images}
onImagesChange={onImagesChange}
/>
{/* Discard Confirmation Dialog */}
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import { checkTaskRunning, isIncompleteHumanReview, getTaskProgress, useTaskStore, loadTasks } from '../../../stores/task-store';
import type { Task, TaskLogs, TaskLogPhase, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
import type { Task, TaskLogs, TaskLogPhase, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo, ImageAttachment } from '../../../../shared/types';
/**
* Validates task subtasks structure to prevent infinite loops during resume.
@@ -50,6 +50,7 @@ export interface UseTaskDetailOptions {
export function useTaskDetail({ task }: UseTaskDetailOptions) {
const [feedback, setFeedback] = useState('');
const [feedbackImages, setFeedbackImages] = useState<ImageAttachment[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState('overview');
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
@@ -161,6 +162,11 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
}
}, [activeTab]);
// Reset feedback images when task changes to prevent image leakage between tasks
useEffect(() => {
setFeedbackImages([]);
}, [task.id]);
// Load worktree status when task is in human_review
useEffect(() => {
if (needsReview) {
@@ -255,6 +261,26 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
});
}, []);
// Add a feedback image
const addFeedbackImage = useCallback((image: ImageAttachment) => {
setFeedbackImages(prev => [...prev, image]);
}, []);
// Add multiple feedback images at once
const addFeedbackImages = useCallback((images: ImageAttachment[]) => {
setFeedbackImages(prev => [...prev, ...images]);
}, []);
// Remove a feedback image by ID
const removeFeedbackImage = useCallback((imageId: string) => {
setFeedbackImages(prev => prev.filter(img => img.id !== imageId));
}, []);
// Clear all feedback images
const clearFeedbackImages = useCallback(() => {
setFeedbackImages([]);
}, []);
// Track if we've already loaded preview for this task to prevent infinite loops
const hasLoadedPreviewRef = useRef<string | null>(null);
@@ -404,6 +430,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
return {
// State
feedback,
feedbackImages,
isSubmitting,
activeTab,
isUserScrolledUp,
@@ -447,6 +474,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
// Setters
setFeedback,
setFeedbackImages,
setIsSubmitting,
setActiveTab,
setIsUserScrolledUp,
@@ -482,6 +510,10 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
handleLogsScroll,
togglePhase,
loadMergePreview,
addFeedbackImage,
addFeedbackImages,
removeFeedbackImage,
clearFeedbackImages,
handleReviewAgain,
reloadPlanForIncompleteTask,
};
@@ -1,4 +1,5 @@
import { CheckCircle, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../ui/button';
import { cn } from '../../../lib/utils';
import type { MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
@@ -20,6 +21,7 @@ export function MergePreviewSummary({
mergePreview,
onShowConflictDialog
}: MergePreviewSummaryProps) {
const { t } = useTranslation(['taskReview']);
const hasGitConflicts = mergePreview.gitConflicts?.hasConflicts;
const hasAIConflicts = mergePreview.conflicts.length > 0;
const hasHighSeverity = mergePreview.conflicts.some(
@@ -72,8 +74,8 @@ export function MergePreviewSummary({
<div className="mb-3 p-2 bg-warning/10 rounded text-xs border border-warning/30">
<p className="font-medium text-warning mb-1">Branch has diverged - AI will resolve</p>
<p className="text-muted-foreground mb-2">
The main branch has {mergePreview.gitConflicts.commitsBehind} new commit{mergePreview.gitConflicts.commitsBehind !== 1 ? 's' : ''} since this worktree was created.
{mergePreview.gitConflicts.conflictingFiles.length} file{mergePreview.gitConflicts.conflictingFiles.length !== 1 ? 's' : ''} will need intelligent merging:
{t('taskReview:merge.branchHasNewCommitsSinceWorktree', { branch: mergePreview.gitConflicts.baseBranch, count: mergePreview.gitConflicts.commitsBehind })}
{' '}{t('taskReview:merge.filesNeedIntelligentMerging', { count: mergePreview.gitConflicts.conflictingFiles.length })}
</p>
<ul className="list-disc list-inside text-muted-foreground">
{mergePreview.gitConflicts.conflictingFiles.map((file, idx) => (
@@ -1,54 +1,376 @@
import { AlertCircle, RotateCcw, Loader2 } from 'lucide-react';
import { useCallback, useRef, useState, type ClipboardEvent, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertCircle, RotateCcw, Loader2, Image as ImageIcon, X } from 'lucide-react';
import { Button } from '../../ui/button';
import { Textarea } from '../../ui/textarea';
import {
generateImageId,
blobToBase64,
createThumbnail,
isValidImageMimeType,
resolveFilename
} from '../../ImageUpload';
import { cn } from '../../../lib/utils';
import type { ImageAttachment } from '../../../../shared/types';
import {
MAX_IMAGES_PER_TASK,
ALLOWED_IMAGE_TYPES_DISPLAY
} from '../../../../shared/constants';
interface QAFeedbackSectionProps {
feedback: string;
isSubmitting: boolean;
onFeedbackChange: (value: string) => void;
onReject: () => void;
/** Image attachments for visual feedback - optional for backward compatibility */
images?: ImageAttachment[];
/** Callback when images change - optional for backward compatibility */
onImagesChange?: (images: ImageAttachment[]) => void;
}
/**
* Displays the QA feedback section where users can request changes
* Supports image paste and drag-drop for visual feedback
*/
export function QAFeedbackSection({
feedback,
isSubmitting,
onFeedbackChange,
onReject
onReject,
images = [],
onImagesChange
}: QAFeedbackSectionProps) {
const { t } = useTranslation('tasks');
// Feature is enabled when onImagesChange callback is provided
const imageUploadEnabled = !!onImagesChange;
// Ref for the textarea
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Local state for UI feedback
const [isDragOverTextarea, setIsDragOverTextarea] = useState(false);
const [pasteSuccess, setPasteSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Handle paste event for screenshot support
*/
const handlePaste = useCallback(async (e: ClipboardEvent<HTMLTextAreaElement>) => {
// Skip image handling if feature is not enabled
if (!onImagesChange) return;
const clipboardItems = e.clipboardData?.items;
if (!clipboardItems) return;
// Find image items in clipboard
const imageItems: DataTransferItem[] = [];
for (let i = 0; i < clipboardItems.length; i++) {
const item = clipboardItems[i];
if (item.type.startsWith('image/')) {
imageItems.push(item);
}
}
// If no images, allow normal paste behavior
if (imageItems.length === 0) return;
// Prevent default paste when we have images
e.preventDefault();
// Check if we can add more images
const remainingSlots = MAX_IMAGES_PER_TASK - images.length;
if (remainingSlots <= 0) {
setError(t('feedback.maxImagesError', { count: MAX_IMAGES_PER_TASK }));
return;
}
setError(null);
// Process image items
const newImages: ImageAttachment[] = [];
const existingFilenames = images.map(img => img.filename);
for (const item of imageItems.slice(0, remainingSlots)) {
const file = item.getAsFile();
if (!file) continue;
// Validate image type
if (!isValidImageMimeType(file.type)) {
setError(t('feedback.invalidTypeError', { types: ALLOWED_IMAGE_TYPES_DISPLAY }));
continue;
}
try {
const dataUrl = await blobToBase64(file);
const thumbnail = await createThumbnail(dataUrl);
// Generate filename for pasted images (screenshot-timestamp.ext)
// Map MIME types to proper file extensions (handles svg+xml -> svg, etc.)
const mimeToExtension: Record<string, string> = {
'image/svg+xml': 'svg',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
};
const extension = mimeToExtension[file.type] || file.type.split('/')[1] || 'png';
const baseFilename = `screenshot-${Date.now()}.${extension}`;
const resolvedFilename = resolveFilename(baseFilename, [
...existingFilenames,
...newImages.map(img => img.filename)
]);
newImages.push({
id: generateImageId(),
filename: resolvedFilename,
mimeType: file.type,
size: file.size,
data: dataUrl.split(',')[1], // Store base64 without data URL prefix
thumbnail
});
} catch (error) {
console.error('[QAFeedbackSection] Failed to process pasted image:', error);
setError(t('feedback.processingError', 'Failed to process pasted image'));
}
}
if (newImages.length > 0) {
onImagesChange([...images, ...newImages]);
// Show success feedback
setPasteSuccess(true);
setTimeout(() => setPasteSuccess(false), 2000);
}
}, [images, onImagesChange, t]);
/**
* Handle drag over textarea for image drops
*/
const handleTextareaDragOver = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverTextarea(true);
}, []);
/**
* Handle drag leave from textarea
*/
const handleTextareaDragLeave = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverTextarea(false);
}, []);
/**
* Handle drop on textarea for images
*/
const handleTextareaDrop = useCallback(
async (e: DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverTextarea(false);
// Skip image handling if feature is not enabled
if (!onImagesChange) return;
if (isSubmitting) return;
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
// Filter for image files
const imageFiles: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
imageFiles.push(file);
}
}
if (imageFiles.length === 0) return;
// Check if we can add more images
const remainingSlots = MAX_IMAGES_PER_TASK - images.length;
if (remainingSlots <= 0) {
setError(t('feedback.maxImagesError', { count: MAX_IMAGES_PER_TASK }));
return;
}
setError(null);
// Process image files
const newImages: ImageAttachment[] = [];
const existingFilenames = images.map(img => img.filename);
for (const file of imageFiles.slice(0, remainingSlots)) {
// Validate image type
if (!isValidImageMimeType(file.type)) {
setError(t('feedback.invalidTypeError', { types: ALLOWED_IMAGE_TYPES_DISPLAY }));
continue;
}
try {
const dataUrl = await blobToBase64(file);
const thumbnail = await createThumbnail(dataUrl);
// Use original filename or generate one with proper extension
// Map MIME types to proper file extensions (handles svg+xml -> svg, etc.)
const mimeToExtension: Record<string, string> = {
'image/svg+xml': 'svg',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
};
const extension = mimeToExtension[file.type] || file.type.split('/')[1] || 'png';
const baseFilename = file.name || `dropped-image-${Date.now()}.${extension}`;
const resolvedFilename = resolveFilename(baseFilename, [
...existingFilenames,
...newImages.map(img => img.filename)
]);
newImages.push({
id: generateImageId(),
filename: resolvedFilename,
mimeType: file.type,
size: file.size,
data: dataUrl.split(',')[1], // Store base64 without data URL prefix
thumbnail
});
} catch (error) {
console.error('[QAFeedbackSection] Failed to process dropped image:', error);
setError(t('feedback.processingError', 'Failed to process dropped image'));
}
}
if (newImages.length > 0) {
onImagesChange([...images, ...newImages]);
// Show success feedback
setPasteSuccess(true);
setTimeout(() => setPasteSuccess(false), 2000);
}
},
[images, isSubmitting, onImagesChange, t]
);
/**
* Remove an image from the attachments
*/
const handleRemoveImage = useCallback((imageId: string) => {
if (!onImagesChange) return;
onImagesChange(images.filter(img => img.id !== imageId));
setError(null);
}, [images, onImagesChange]);
// Allow submission with either text feedback or images
const canSubmit = feedback.trim() || images.length > 0;
return (
<div className="rounded-xl border border-warning/30 bg-warning/10 p-4">
<h3 className="font-medium text-sm text-foreground mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-warning" />
Request Changes
{t('feedback.requestChanges', 'Request Changes')}
</h3>
<p className="text-sm text-muted-foreground mb-3">
Found issues? Describe what needs to be fixed and the AI will continue working on it.
{t('feedback.description', 'Found issues? Describe what needs to be fixed and the AI will continue working on it.')}
</p>
{/* Textarea with paste/drop support */}
<Textarea
placeholder="Describe the issues or changes needed..."
ref={textareaRef}
placeholder={t('feedback.placeholder', 'Describe the issues or changes needed...')}
value={feedback}
onChange={(e) => onFeedbackChange(e.target.value)}
className="mb-3"
onPaste={handlePaste}
onDragOver={handleTextareaDragOver}
onDragLeave={handleTextareaDragLeave}
onDrop={handleTextareaDrop}
className={cn(
"mb-2",
// Visual feedback when dragging over textarea
isDragOverTextarea && !isSubmitting && "border-primary bg-primary/5 ring-2 ring-primary/20"
)}
rows={3}
disabled={isSubmitting}
/>
{/* Drag/paste hint - only show when feature is enabled */}
{imageUploadEnabled && (
<p className="text-xs text-muted-foreground mb-2">
{t('feedback.dragDropHint', 'Drag & drop images or paste screenshots')}
</p>
)}
{/* Paste Success Indicator */}
{pasteSuccess && (
<div className="flex items-center gap-2 text-sm text-success mb-2 animate-in fade-in slide-in-from-top-1 duration-200">
<ImageIcon className="h-4 w-4" />
{t('feedback.imageAdded', 'Image added successfully!')}
</div>
)}
{/* Error display */}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-2 text-sm text-destructive mb-2">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Image Thumbnails - displayed inline below textarea */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{images.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-border overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
style={{ width: '64px', height: '64px' }}
title={image.filename}
>
{image.thumbnail ? (
<img
src={image.thumbnail}
alt={image.filename}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
<ImageIcon className="h-6 w-6 text-muted-foreground" />
</div>
)}
{/* Remove button */}
{!isSubmitting && (
<button
type="button"
className="absolute top-0.5 right-0.5 h-4 w-4 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleRemoveImage(image.id);
}}
aria-label={t('feedback.removeImage', 'Remove image')}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
)}
<Button
variant="warning"
onClick={onReject}
disabled={isSubmitting || !feedback.trim()}
disabled={isSubmitting || !canSubmit}
className="w-full"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
{t('feedback.submitting', 'Submitting...')}
</>
) : (
<>
<RotateCcw className="mr-2 h-4 w-4" />
Request Changes
{t('feedback.requestChanges', 'Request Changes')}
</>
)}
</Button>

Some files were not shown because too many files have changed in this diff Show More