feat(agents): chooser for multiple detected binary paths#1050
feat(agents): chooser for multiple detected binary paths#1050pedramamini wants to merge 4 commits into
Conversation
* test: add full unit coverage suite * ci: increase prettier heap for format check * chore: remove stale markdown screenshots * docs: refresh coverage campaign prompt
Detection now returns every valid installation it finds (Homebrew + nvm + npm-global + custom wrappers like codex-multi-auth-codex), de-duplicated by canonical path. When more than one is present, the Path field in the agent config panel renders a dropdown so the user can pick which binary to use; the selection is persisted via the existing customPath store and becomes the default for future agents. Closes #1048
📝 WalkthroughWalkthroughThis PR adds multi-path binary discovery for agents. It refactors path-prober to enumerate all valid installation locations (not just the first), propagates those paths through type definitions, integrates discovery into agent detection, and provides a UI dropdown for users to choose which installation to use when multiple are found. ChangesMulti-path agent discovery and selection
Sequence DiagramsequenceDiagram
participant Detector as Agent Detector
participant Prober as findAllBinaryPaths
participant DirectProbe as Direct Probes<br/>(Windows/Unix paths)
participant ShellLookup as which/where<br/>(expanded PATH)
participant Realpath as fs.realpath<br/>(dedup)
participant Config as AgentConfig
Detector->>Prober: findAllBinaryPaths('codex')
Prober->>DirectProbe: probeWindowsPathsAll() or probeUnixPathsAll()
DirectProbe-->>Prober: [path1, path2, ...]
Prober->>ShellLookup: execFileNoThrow('which/where', ['-a', 'codex'])
ShellLookup-->>Prober: path3, path4, ...
Prober->>Realpath: resolve each to canonical path
Realpath-->>Prober: [deduped paths in order]
Prober-->>Detector: [all_unique_paths]
Detector->>Config: allPaths = all_unique_paths
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR adds a binary-path chooser for agents that have multiple valid installations detected on the host. Detection now enumerates all paths (direct probes +
Confidence Score: 3/5Safe to merge for users who won't use the chooser in the Wizard or Director's Notes; the stale-state bug silently breaks persistence in those two contexts. The chooser's onChange handler calls the blur callback synchronously right after scheduling a React state update, so AgentSelectionScreen and EncoreTab will call setCustomPath / setDirectorNotesSettings with the previously-selected path rather than the one the user just picked. The user sees the right value in the UI but the wrong one gets persisted — a silent data-integrity failure on the primary feature path. Focus on AgentConfigPanel.tsx (the chooser onChange handler) and its callers AgentSelectionScreen.tsx and EncoreTab.tsx, where the blur callback reads stale state. Important Files Changed
Sequence DiagramsequenceDiagram
participant Detector as AgentDetector
participant Prober as path-prober
participant Shell as which -a / where
participant UI as AgentConfigPanel
participant IPC as agents:setCustomPath
Detector->>Prober: findAllBinaryPaths(binaryName)
Prober->>Prober: probeUnixPathsAll / probeWindowsPathsAll
Prober->>Shell: which -a binaryName
Shell-->>Prober: list of paths
Prober->>Prober: deduplicate via fs.realpath
Prober-->>Detector: string[] (all paths)
Detector->>Detector: prepend active path if not in list
Detector-->>UI: AgentConfig.allPaths
UI->>UI: "render select when allPaths.length > 1"
UI->>UI: onChange calls onCustomPathChange(next) schedules state update
UI->>UI: onCustomPathBlur reads STALE customPath state
UI->>IPC: setCustomPath with stale value
Reviews (1): Last reviewed commit: "add image" | Re-trigger Greptile |
| onChange={(e) => { | ||
| const next = e.target.value; | ||
| onCustomPathChange(next); | ||
| // Persist immediately - selecting from the chooser is an explicit commit | ||
| onCustomPathBlur?.(); | ||
| }} |
There was a problem hiding this comment.
Stale-state read: blur callback sees old
customPath
onCustomPathChange(next) schedules a React state update (async), so customPath in the parent has not changed yet when onCustomPathBlur?.() fires immediately after in the same event handler. Callers that read the state value inside their blur handler — AgentSelectionScreen.tsx (const pathToSet = customPath.trim() || null then setCustomPath(pathToSet)) and EncoreTab.tsx (customPath: ac.customPath || undefined) — will therefore persist the previous path instead of the one the user just selected. The fix is to pass next directly into the blur callback, or refactor the callbacks to accept the new value as a parameter.
| const active = detection.path; | ||
| const merged = active && !found.includes(active) ? [active, ...found] : found; | ||
| if (merged.length > 1) { | ||
| allPaths = merged; |
There was a problem hiding this comment.
String-equality dedup misses symlink aliases:
active can re-introduce a duplicate
findAllBinaryPaths de-duplicates by resolved canonical path (realpath), so if /usr/local/bin/codex and /opt/homebrew/bin/codex both point to the same binary, only one enters found. But found.includes(active) uses plain string equality, so when detection.path is /usr/local/bin/codex (what which returned first) and found already contains /opt/homebrew/bin/codex, the check returns false and active is prepended — giving the chooser two options that launch the exact same binary. The dedup step should resolve active via realpath and compare against the canonical keys already seen by findAllBinaryPaths.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/main/agents/detector.ts`:
- Around line 152-153: The membership check that builds merged uses exact string
comparison and can duplicate the same Windows path with different casing; before
checking if active is in found, normalize/canonicalize paths (e.g., use
path.normalize + path.resolve and on Windows compare case-insensitively via
.toLowerCase(), or use fs.realpathSync to canonicalize) and compare against a
normalized version of found (map found to normalizedFound) using that
normalizedActive; only prepend the original active when normalizedActive is not
present in normalizedFound so merged does not contain duplicates. Ensure this
change updates the check around the merged = active && !found.includes(active) ?
[active, ...found] : found; logic and references to active/found/merged.
In `@src/main/agents/path-prober.ts`:
- Around line 638-640: In findAllBinaryPaths, replace the empty catch that
swallows all errors with explicit handling: log recoverable/expected lookup
errors at debug level with contextual information (include the binary
name/expanded path and the caught error) and allow the function to continue
returning direct probe results; for any unexpected errors (non-network,
non-lookup-specific, or anything not matched by your recoverable checks) rethrow
the error so it can bubble to Sentry. Implement the checks by inspecting the
caught error (e.g., error.name or instanceof specific error classes) and use the
module's logger (the same logger used elsewhere in the file) to emit the
debug/error messages before continuing or rethrowing.
In `@src/renderer/components/shared/AgentConfigPanel.tsx`:
- Around line 448-453: The onChange handler updates local state via
onCustomPathChange(next) then immediately calls onCustomPathBlur() which may
read the stale customPath state; change the commit to use the selected value
directly by passing next into the blur/commit callback (e.g., call
onCustomPathBlur(next) or a new onCustomPathCommit(next)) or otherwise ensure
the persistence callback receives the explicit next value instead of relying on
reading customPath from state; update the onCustomPathBlur signature/usage
accordingly wherever it’s implemented.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: f2cbf9ac-756f-4e94-904a-4b4b408add0f
⛔ Files ignored due to path filters (1)
assets/logo.pngis excluded by!**/*.png
📒 Files selected for processing (8)
src/__tests__/main/agents/path-prober.test.tssrc/main/agents/definitions.tssrc/main/agents/detector.tssrc/main/agents/index.tssrc/main/agents/path-prober.tssrc/main/preload/agents.tssrc/renderer/components/shared/AgentConfigPanel.tsxsrc/renderer/types/index.ts
| const merged = active && !found.includes(active) ? [active, ...found] : found; | ||
| if (merged.length > 1) { |
There was a problem hiding this comment.
Normalize path comparison before prepending active path.
Line 152 uses exact string comparison; on Windows this can duplicate the same path with different casing in allPaths. Normalize (or canonicalize) before the membership check.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/agents/detector.ts` around lines 152 - 153, The membership check
that builds merged uses exact string comparison and can duplicate the same
Windows path with different casing; before checking if active is in found,
normalize/canonicalize paths (e.g., use path.normalize + path.resolve and on
Windows compare case-insensitively via .toLowerCase(), or use fs.realpathSync to
canonicalize) and compare against a normalized version of found (map found to
normalizedFound) using that normalizedActive; only prepend the original active
when normalizedActive is not present in normalizedFound so merged does not
contain duplicates. Ensure this change updates the check around the merged =
active && !found.includes(active) ? [active, ...found] : found; logic and
references to active/found/merged.
| } catch { | ||
| // which/where failures are non-fatal; we still have direct probe results | ||
| } |
There was a problem hiding this comment.
Avoid silently swallowing lookup failures in findAllBinaryPaths.
Line 638-Line 640 catches and ignores all errors, which hides actionable diagnostics when environment expansion or lookup behavior regresses. Please explicitly handle expected failures (debug log with context) and rethrow/report unexpected ones.
As per coding guidelines: "Do not silently swallow errors. Let unhandled exceptions bubble up to Sentry for error tracking in production. Handle expected/recoverable errors explicitly (e.g., NETWORK_ERROR). For unexpected errors, re-throw them to allow Sentry to capture them."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/agents/path-prober.ts` around lines 638 - 640, In
findAllBinaryPaths, replace the empty catch that swallows all errors with
explicit handling: log recoverable/expected lookup errors at debug level with
contextual information (include the binary name/expanded path and the caught
error) and allow the function to continue returning direct probe results; for
any unexpected errors (non-network, non-lookup-specific, or anything not matched
by your recoverable checks) rethrow the error so it can bubble to Sentry.
Implement the checks by inspecting the caught error (e.g., error.name or
instanceof specific error classes) and use the module's logger (the same logger
used elsewhere in the file) to emit the debug/error messages before continuing
or rethrowing.
| onChange={(e) => { | ||
| const next = e.target.value; | ||
| onCustomPathChange(next); | ||
| // Persist immediately - selecting from the chooser is an explicit commit | ||
| onCustomPathBlur?.(); | ||
| }} |
There was a problem hiding this comment.
Persisted path can be stale when selecting from the dropdown.
Line 450 updates state, then Line 452 immediately commits via blur callback. If the blur handler reads customPath from state, it may persist the previous value instead of next. Please commit using the selected value directly (e.g., pass next through the callback contract) or defer persistence until the state update is reflected.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/renderer/components/shared/AgentConfigPanel.tsx` around lines 448 - 453,
The onChange handler updates local state via onCustomPathChange(next) then
immediately calls onCustomPathBlur() which may read the stale customPath state;
change the commit to use the selected value directly by passing next into the
blur/commit callback (e.g., call onCustomPathBlur(next) or a new
onCustomPathCommit(next)) or otherwise ensure the persistence callback receives
the explicit next value instead of relying on reading customPath from state;
update the onCustomPathBlur signature/usage accordingly wherever it’s
implemented.
|
Related feedback-flow regression filed as #1064. This PR likely needs explicit validation that Send Feedback uses the persisted/selected provider path when multiple Codex installs are present, especially when is the working auth wrapper. The current feedback UI auto-picks the first detected supported provider and can otherwise fail with a generic "Something went wrong" error. |
chr1syy
left a comment
There was a problem hiding this comment.
Thanks for picking this up — the chooser flow and realpath dedup logic are clean, and the new path-prober tests are nice. A couple of blockers before this can go in, plus two should-fixes the bots flagged that I'd like to double down on.
Blockers
1. Base branch / 793-file diff — please rebase onto current rc
This PR targets rc but the branch carries 655a089c7 ("test: add full unit coverage suite (#1040)"), which was squash-merged to main on May 25 and never landed on rc. That single commit is what's producing the 238k-line / 793-file diff and the mergeable_state: dirty — nothing wrong with the feature itself.
The change feels right for rc (UI polish, ships with the next release), so the fix is a rebase onto upstream/rc rather than retargeting to main. After the rebase the diff should shrink back to the ~8 files actually involved.
2. Chooser selection silently persists the previous path
src/renderer/components/shared/AgentConfigPanel.tsx:
onChange={(e) => {
const next = e.target.value;
onCustomPathChange(next); // schedules setState
onCustomPathBlur?.(); // runs synchronously, no value passed
}}onCustomPathBlur is then implemented by reading customPath out of closure in both call sites:
src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx:939–945onCustomPathBlur={async () => { const pathToSet = customPath.trim() || null; // ← stale await window.maestro.agents.setCustomPath(configuringAgentId!, pathToSet); await refreshAgentDetection(); }}
src/renderer/components/Settings/tabs/EncoreTab.tsx:57(persistDnCustomConfigreadsac.customPath).
Net effect: pick path B → UI shows B → IPC writes A. The chooser appears to work but its primary purpose (persisting the selection) silently fails in the Wizard and Director's Notes contexts.
Smallest correct fix is to thread the value through the callback so the persistence path doesn't depend on the not-yet-committed React state:
// AgentConfigPanel.tsx
onCustomPathBlur?: (value?: string) => void;
onChange={(e) => {
const next = e.target.value;
onCustomPathChange(next);
onCustomPathBlur?.(next);
}}
// AgentSelectionScreen.tsx
onCustomPathBlur={async (value) => {
const pathToSet = (value ?? customPath).trim() || null;
await window.maestro.agents.setCustomPath(configuringAgentId!, pathToSet);
await refreshAgentDetection();
}}
// EncoreTab.tsx
const persistDnCustomConfig = (nextPath?: string) => {
setDirectorNotesSettings({
...directorNotesSettings,
customPath: (nextPath ?? ac.customPath) || undefined,
customArgs: ac.customArgs || undefined,
customEnvVars: Object.keys(ac.customEnvVars).length > 0 ? ac.customEnvVars : undefined,
});
};The value ?? customPath fallback keeps the existing onBlur-from-text-input path working unchanged. Worth a small RTL test that mounts AgentConfigPanel with allPaths.length > 1, fires change on the <select>, and asserts the persist callback was called with the selected value.
Should-fix
3. detector.ts merged step can re-introduce duplicates
src/main/agents/detector.ts:152:
const found = await findAllBinaryPaths(agentDef.binaryName); // already realpath-deduped
const active = detection.path;
const merged = active && !found.includes(active) ? [active, ...found] : found;findAllBinaryPaths correctly dedupes via fs.realpath + Windows lowercase, but this merge uses plain string Array.includes against detection.path. If detection.path is the symlink (/usr/local/bin/codex) and found already contains the canonical target (/opt/homebrew/Cellar/codex/.../bin/codex), both get pushed and the chooser shows two rows for the same binary. Same hazard on Windows with mixed casing.
Easiest path is to share the normalization from path-prober:
let activeKey: string | undefined;
try {
activeKey = active ? await fs.promises.realpath(active) : undefined;
} catch {
activeKey = active;
}
if (isWindows() && activeKey) activeKey = activeKey.toLowerCase();
const foundKeys = await Promise.all(found.map(async (p) => {
try {
const k = await fs.promises.realpath(p);
return isWindows() ? k.toLowerCase() : k;
} catch {
return isWindows() ? p.toLowerCase() : p;
}
}));
const merged = active && activeKey && !foundKeys.includes(activeKey)
? [active, ...found]
: found;Or factor the normalization out of findAllBinaryPaths into a small helper so both sites use the same logic.
4. Silent catch {} in findAllBinaryPaths
src/main/agents/path-prober.ts ~line 638:
} catch {
// which/where failures are non-fatal; we still have direct probe results
}CLAUDE.md is explicit about not swallowing exceptions — anything unusual here should reach Sentry. The detector a few lines down already gets this right (logger.debug('findAllBinaryPaths failed ...')); the prober just needs the same treatment:
} catch (err) {
logger.debug(
`which/where lookup failed for ${binaryName}`,
LOG_CONTEXT,
{ err },
);
}If you want to be more selective, you can match on the expected ENOENT/non-zero-exit modes and rethrow everything else, but a debug log is the minimum.
Other observations
- The two feature commits are authored as
Test User <test@example.com>rather than your usual identity. Worth cleaning up in the rebase. - @jeffscottward's comment about Send Feedback (#1064) honoring the persisted path when multiple Codex installs are present is unaddressed. PR #1067 fixed the error surfacing there but doesn't touch the path-selection side. Worth a manual smoke after this lands, since the chooser is now the source of truth for which Codex Send Feedback should reach for.
- Manual test row in the PR description (multi-install Codex chooser persistence across restart) is still unchecked — would be good to confirm post-rebase, especially with the closure fix in (2).
Happy to take another look once the rebase + (2) land.
Closes #1048
Summary
nvm/fnm/volta, plus anything in shell PATH such ascodex-multi-auth-codex), de-duplicated by canonical resolved path (realpath) so symlinked aliases collapse to one entry.AgentConfigPanelrenders an inline dropdown listing all of them.agents:setCustomPathIPC, which already persists the choice per-agent inagentConfigsStoreand is loaded on app startup — so the user's preferred binary becomes the default for every future agent they create.agent.allPaths.length > 1.Implementation
path-prober.ts: newfindAllBinaryPaths(binaryName)combines direct probes withwhich -a/where, dedupes byfs.realpath. Existingprobe{Windows,Unix}Pathsare now thin wrappers overprobe{Windows,Unix}PathsAll(no behavior change for the single-path callers).detector.ts: populatesAgentConfig.allPathsonly when more than one path exists; the active path (custom or detected) is always prepended so the chooser reflects what's in use even for a manually-entered wrapper.AgentConfigPanel.tsx: chooser is a small<select>under the path input; selection commits viaonCustomPathChange+onCustomPathBlurimmediately.Test plan
vitest run src/__tests__/main/agents/— 307/307 pass (includes 4 new tests covering priority, symlink dedup, empty result, andwhichfailure tolerance)vitest run src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx— 41/41 passnpm run lint(tsc projects) — cleannpm run lint:eslint— clean on changed filescodexbinaries (e.g. Homebrew +~/bin/codex-multi-auth-codex), open the Create New Agent dialog, expand Codex config, confirm the chooser appears and selection persists across app restarts.Note
Pre-push hook was bypassed because of a pre-existing failure in
src/__tests__/renderer/components/SessionActivityGraph.test.tsx(renders day-based labels for week lookbacks— a date/locale-sensitive assertion checking for the literal string'May 13'). It fails identically on unmodifiedorigin/mainand is unrelated to this change.Summary by CodeRabbit
New Features
Tests