Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions check_types.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Check if Object.fromEntries result is assignable to Partial<Record<SpawnAgentCli, boolean>>

// According to TypeScript:
// Partial<Record<SpawnAgentCli, boolean>> expands to:
// { claude?: boolean; codex?: boolean; opencode?: boolean }

// Object.fromEntries returns: { [k: string]: any }
// In the context of:
// const results: Array<[SpawnAgentCli, boolean]> = [['claude', true], ...]
// Object.fromEntries(results) is typed as: { [k: string]: boolean }

// The question: is { [k: string]: boolean } assignable to
// { claude?: boolean; codex?: boolean; opencode?: boolean }?

// Answer: YES, because:
// 1. { [k: string]: boolean } is an index signature that matches ANY string key
// 2. { claude?: boolean; codex?: boolean; opencode?: boolean } only uses specific keys
// 3. Any value with an index signature { [k: string]: T } satisfies a type with specific optional properties

console.log("Type check analysis:");
console.log("Object.fromEntries(results) type: { [k: string]: boolean }");
console.log("setCliAvailability expects: Partial<Record<SpawnAgentCli, boolean>>");
console.log("Which expands to: { claude?: boolean; codex?: boolean; opencode?: boolean }");
console.log("");
console.log("TypeScript assignability: YES - index signature types are assignable to property-specific types");
28 changes: 27 additions & 1 deletion src/main/broker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,15 @@ vi.mock('./burn', () => ({
getPearBurnAgentKey: vi.fn((projectId: string, name: string) => `${projectId}:${name}`)
}))

import { BrokerManager, resolveAgentRelayMcpCommand } from './broker'
import { BrokerManager, isCommandAvailableWithAugmentedPath, resolveAgentRelayMcpCommand } from './broker'

const PROJECT_ID = 'project-1'
const originalMcpCommand = process.env.AGENT_RELAY_MCP_COMMAND
const originalResourcesPathDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath')
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
const originalPublicEnv = process.env.PUBLIC
const originalProgramDataEnv = process.env.ProgramData
const originalPathEnv = process.env.PATH

function setProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
Expand Down Expand Up @@ -1159,3 +1160,28 @@ describe('BrokerManager spawnAgent CLI preflight', () => {
await manager.shutdown()
})
})

describe('isCommandAvailableWithAugmentedPath', () => {
let tempDir: string | null = null

afterEach(async () => {
process.env.PATH = originalPathEnv
if (tempDir) await rm(tempDir, { recursive: true, force: true })
tempDir = null
})

it('resolves commands without mutating PATH', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'pear-cli-path-'))
const toolPath = join(tempDir, 'opencode')
await writeFile(toolPath, '#!/bin/sh\nexit 0\n')
await chmod(toolPath, 0o755)
process.env.PATH = tempDir

expect(isCommandAvailableWithAugmentedPath('opencode')).toBe(true)
expect(process.env.PATH).toBe(tempDir)
})

it('returns false for blank commands', () => {
expect(isCommandAvailableWithAugmentedPath(' ')).toBe(false)
})
})
11 changes: 10 additions & 1 deletion src/main/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function augmentedPath(): string {
return Array.from(entries).join(delimiter)
}

function resolveCommandWithAugmentedPath(command: string): string | undefined {
export function resolveCommandWithAugmentedPath(command: string): string | undefined {
const resolved = resolveCommandOnPath(command)
if (resolved) return resolved

Expand All @@ -96,6 +96,15 @@ function resolveCommandWithAugmentedPath(command: string): string | undefined {
return undefined
}

export function isCommandAvailableWithAugmentedPath(command: string): boolean {
const trimmed = command.trim()
if (!trimmed) return false
return Boolean(resolveCommandOnPath(trimmed, {
...process.env,
PATH: augmentedPath()
}))
}

function executableCliPath(input: SpawnPtyInput): string {
return isAbsolute(input.cli)
? input.cli
Expand Down
35 changes: 34 additions & 1 deletion src/main/ipc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const mock = vi.hoisted(() => {
detachCloudSandbox: vi.fn(),
onBrokerEvent: vi.fn()
},
isCommandAvailableWithAugmentedPath: vi.fn(),
integrationsManager: {
notifyAgentState: vi.fn(async () => undefined),
initialSpawnInstructions: vi.fn(),
Expand Down Expand Up @@ -101,7 +102,8 @@ vi.mock('./store', () => ({
}))

vi.mock('./broker', () => ({
brokerManager: mock.brokerManager
brokerManager: mock.brokerManager,
isCommandAvailableWithAugmentedPath: mock.isCommandAvailableWithAugmentedPath
}))

vi.mock('./git', () => ({}))
Expand Down Expand Up @@ -197,3 +199,34 @@ describe('registerIpcHandlers broker:spawn-agent', () => {
expect(() => structuredClone(result)).not.toThrow()
})
})

describe('registerIpcHandlers broker:check-cli-available', () => {
beforeEach(() => {
mock.handlers.clear()
mock.ipcMain.handle.mockClear()
mock.ipcMain.on.mockClear()
mock.isCommandAvailableWithAugmentedPath.mockReset()
registerIpcHandlers()
})

it('returns whether the requested CLI can be resolved', () => {
const handler = mock.handlers.get('broker:check-cli-available')
expect(handler).toBeTypeOf('function')
mock.isCommandAvailableWithAugmentedPath.mockReturnValueOnce(true)

const result = handler?.({}, 'opencode')

expect(result).toBe(true)
expect(mock.isCommandAvailableWithAugmentedPath).toHaveBeenCalledWith('opencode')
})

it('rejects non-string CLI values', () => {
const handler = mock.handlers.get('broker:check-cli-available')
expect(handler).toBeTypeOf('function')

const result = handler?.({}, null)

expect(result).toBe(false)
expect(mock.isCommandAvailableWithAugmentedPath).not.toHaveBeenCalled()
})
})
6 changes: 5 additions & 1 deletion src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
addProjectIntegration,
removeProjectIntegration
} from './store'
import { brokerManager } from './broker'
import { brokerManager, isCommandAvailableWithAugmentedPath } from './broker'
import * as git from './git'
import * as filesystem from './filesystem'
import * as auth from './auth'
Expand Down Expand Up @@ -351,6 +351,10 @@ export function registerIpcHandlers(): void {
return brokerManager.listAgents(projectId)
})

ipcMain.handle('broker:check-cli-available', (_, cli: string) => {
return typeof cli === 'string' && isCommandAvailableWithAugmentedPath(cli)
})

ipcMain.handle('broker:list-details', async () => {
return brokerManager.listBrokerDetails()
})
Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ const api = {
invoke<void>('broker:release-agent', projectId, name),
listAgents: (projectId?: string) =>
invoke<BrokerListAgent[]>('broker:list-agents', projectId),
checkCliAvailable: (cli: string) =>
invoke<boolean>('broker:check-cli-available', cli),
listDetails: () => invoke<BrokerDetails[]>('broker:list-details'),
listEvents: () => invoke<BrokerEventRecord[]>('broker:list-events'),
shutdown: () => invoke<void>('broker:shutdown'),
Expand Down
32 changes: 25 additions & 7 deletions src/renderer/src/components/sidebar/SpawnAgentDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, X } from 'lucide-react'
import { ClaudeIcon, CodexIcon } from '@/components/common/AgentIcons'
import { listProjectPersonas, spawnProjectAgent, spawnProjectPersona, type SpawnAgentCli } from '@/lib/spawn-agent'
import type { WorkforcePersona } from '@/lib/ipc'
import { ClaudeIcon, CodexIcon, OpenCodeIcon } from '@/components/common/AgentIcons'
import { SPAWN_AGENT_CLI_INSTALL_COMMANDS, listProjectPersonas, spawnProjectAgent, spawnProjectPersona, type SpawnAgentCli } from '@/lib/spawn-agent'
import { pear, type WorkforcePersona } from '@/lib/ipc'
import { useProjectStore, type ProjectRoot } from '@/stores/project-store'
import { useUIStore } from '@/stores/ui-store'

const AGENT_OPTIONS: Array<{ cli: SpawnAgentCli; label: string; Icon: typeof ClaudeIcon }> = [
{ cli: 'claude', label: 'Claude', Icon: ClaudeIcon },
{ cli: 'codex', label: 'Codex', Icon: CodexIcon }
{ cli: 'codex', label: 'Codex', Icon: CodexIcon },
{ cli: 'opencode', label: 'OpenCode', Icon: OpenCodeIcon }
]
Comment on lines 10 to 14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The type of the Icon property in AGENT_OPTIONS is hardcoded to typeof ClaudeIcon. While this works because all agent icons share the same signature, it is more idiomatic and maintainable to use a generic React component type. This prevents potential type issues when adding new agent icons with different implementations in the future.

Suggested change
const AGENT_OPTIONS: Array<{ cli: SpawnAgentCli; label: string; Icon: typeof ClaudeIcon }> = [
{ cli: 'claude', label: 'Claude', Icon: ClaudeIcon },
{ cli: 'codex', label: 'Codex', Icon: CodexIcon }
{ cli: 'codex', label: 'Codex', Icon: CodexIcon },
{ cli: 'opencode', label: 'OpenCode', Icon: OpenCodeIcon }
]
const AGENT_OPTIONS: Array<{ cli: SpawnAgentCli; label: string; Icon: React.ComponentType<{ className?: string }> }> = [
{ cli: 'claude', label: 'Claude', Icon: ClaudeIcon },
{ cli: 'codex', label: 'Codex', Icon: CodexIcon },
{ cli: 'opencode', label: 'OpenCode', Icon: OpenCodeIcon }
]


export function SpawnAgentDialog(): React.ReactNode {
Expand All @@ -20,6 +21,7 @@ export function SpawnAgentDialog(): React.ReactNode {
const [selectedPersonaId, setSelectedPersonaId] = useState('')
const [customName, setCustomName] = useState('')
const [error, setError] = useState<string | null>(null)
const [cliAvailability, setCliAvailability] = useState<Partial<Record<SpawnAgentCli, boolean>>>({})
const [selectedRootId, setSelectedRootId] = useState<string | null>(null)
const project = useProjectStore((s) => s.getActiveProject())
const defaultRoot = useProjectStore((s) => s.getActiveRoot())
Expand All @@ -45,6 +47,18 @@ export function SpawnAgentDialog(): React.ReactNode {
}
}, [project, root?.id, selectedRootId])

useEffect(() => {
let cancelled = false
const clis: SpawnAgentCli[] = ['claude', 'codex', 'opencode']
void Promise.all(clis.map(async (cli) => {
const available = await pear.broker.checkCliAvailable(cli).catch(() => false)
return [cli, available] as const
})).then((results) => {
if (!cancelled) setCliAvailability(Object.fromEntries(results))
})
return () => { cancelled = true }
}, [])

useEffect(() => {
let cancelled = false

Expand Down Expand Up @@ -207,16 +221,20 @@ export function SpawnAgentDialog(): React.ReactNode {
className="h-9 w-full rounded-md border border-[var(--pear-border-subtle)] bg-[var(--pear-bg)] px-3 text-sm text-[var(--pear-text)] outline-none placeholder:text-[var(--pear-text-faint)] focus:border-[var(--pear-accent-dim)] disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
{AGENT_OPTIONS.map(({ cli, label, Icon }) => (
<button
key={cli}
type="button"
autoFocus={cli === 'claude'}
onClick={() => handleSpawn(cli)}
disabled={!root?.pathExists || spawning}
disabled={!root?.pathExists || spawning || cliAvailability[cli] === false}
className="flex min-h-[92px] flex-col items-center justify-center gap-2 rounded-lg border border-[var(--pear-border)] text-sm text-[var(--pear-text-dim)] hover:border-[var(--pear-accent-dim)] hover:text-[var(--pear-text)] disabled:cursor-not-allowed disabled:opacity-40"
title={root?.pathExists ? `Spawn ${label}` : `Path not found: ${root?.path || project.rootPath}`}
title={
!root?.pathExists ? `Path not found: ${root?.path || project.rootPath}`
: cliAvailability[cli] === false ? `${label} is not installed - run: ${SPAWN_AGENT_CLI_INSTALL_COMMANDS[cli]}`
: `Spawn ${label}`
}
>
<Icon className="h-6 w-6" />
<span>{spawningCli === cli ? 'Starting' : label}</span>
Expand Down
60 changes: 37 additions & 23 deletions src/renderer/src/components/terminal/TerminalPane.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertTriangle, ChevronLeft, ChevronRight, Columns2, Loader2, Network, PanelTop, X } from 'lucide-react'
import { AgentHarnessIcon, ClaudeIcon, CodexIcon } from '@/components/common/AgentIcons'
import { AgentHarnessIcon, ClaudeIcon, CodexIcon, OpenCodeIcon } from '@/components/common/AgentIcons'
import { GraphView } from '@/components/graph/GraphView'
import { ChatComposerInput } from '@/components/chat/ChatComposerInput'
import { spawnProjectAgent, type SpawnAgentCli } from '@/lib/spawn-agent'
import { SPAWN_AGENT_CLI_INSTALL_COMMANDS, spawnProjectAgent, type SpawnAgentCli } from '@/lib/spawn-agent'
import { formatTokenCount } from '@/lib/format'
import { pear, type BurnAgentInput, type BurnAgentSummary, type TerminalAttachMode } from '@/lib/ipc'
import { getAgentKeyForAgent, type Agent, useAgentStore } from '@/stores/agent-store'
Expand Down Expand Up @@ -432,6 +432,7 @@ export function TerminalPane(): React.ReactNode {
const setTerminalLayout = useUIStore((s) => s.setTerminalLayout)
const [spawningCli, setSpawningCli] = useState<SpawnAgentCli | null>(null)
const [spawnError, setSpawnError] = useState<string | null>(null)
const [cliAvailability, setCliAvailability] = useState<Partial<Record<SpawnAgentCli, boolean>>>({})
const spawnRequestRef = useRef(false)
const [burnSummariesByAgentKey, setBurnSummariesByAgentKey] = useState<Record<string, BurnAgentSummary>>({})
const [splitPage, setSplitPage] = useState(0)
Expand Down Expand Up @@ -567,6 +568,18 @@ export function TerminalPane(): React.ReactNode {
})
}

useEffect(() => {
let cancelled = false
const clis: SpawnAgentCli[] = ['claude', 'codex', 'opencode']
void Promise.all(clis.map(async (cli) => {
const available = await pear.broker.checkCliAvailable(cli).catch(() => false)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return [cli, available] as const
})).then((results) => {
if (!cancelled) setCliAvailability(Object.fromEntries(results))
})
return () => { cancelled = true }
}, [])

useEffect(() => {
let cancelled = false

Expand Down Expand Up @@ -781,27 +794,28 @@ export function TerminalPane(): React.ReactNode {
{activeProject ? 'No agents running' : 'No project selected'}
</p>
{activeProject ? (
<div className="mt-4 grid w-full max-w-[340px] grid-cols-2 gap-3">
<button
type="button"
onClick={() => handleSpawn('claude')}
disabled={!activeRoot?.pathExists || spawningCli !== null}
className="flex items-center justify-center gap-2 rounded-lg border border-[var(--pear-border)] px-4 py-3 text-sm text-[var(--pear-text-dim)] hover:border-[var(--pear-accent-dim)] hover:text-[var(--pear-text)] disabled:cursor-not-allowed disabled:opacity-40"
title={activeRoot?.pathExists ? 'Spawn Claude' : `Path not found: ${activeRoot?.path || activeProject.rootPath}`}
>
<ClaudeIcon className="h-4 w-4" />
<span>{spawningCli === 'claude' ? 'Starting' : 'Claude'}</span>
</button>
<button
type="button"
onClick={() => handleSpawn('codex')}
disabled={!activeRoot?.pathExists || spawningCli !== null}
className="flex items-center justify-center gap-2 rounded-lg border border-[var(--pear-border)] px-4 py-3 text-sm text-[var(--pear-text-dim)] hover:border-[var(--pear-accent-dim)] hover:text-[var(--pear-text)] disabled:cursor-not-allowed disabled:opacity-40"
title={activeRoot?.pathExists ? 'Spawn Codex' : `Path not found: ${activeRoot?.path || activeProject.rootPath}`}
>
<CodexIcon className="h-4 w-4" />
<span>{spawningCli === 'codex' ? 'Starting' : 'Codex'}</span>
</button>
<div className="mt-4 grid w-full max-w-[420px] grid-cols-3 gap-3">
{([
{ cli: 'claude', label: 'Claude', Icon: ClaudeIcon },
{ cli: 'codex', label: 'Codex', Icon: CodexIcon },
{ cli: 'opencode', label: 'OpenCode', Icon: OpenCodeIcon }
] as const).map(({ cli, label, Icon }) => (
<button
key={cli}
type="button"
onClick={() => handleSpawn(cli)}
disabled={!activeRoot?.pathExists || spawningCli !== null || cliAvailability[cli] === false}
className="flex items-center justify-center gap-2 rounded-lg border border-[var(--pear-border)] px-4 py-3 text-sm text-[var(--pear-text-dim)] hover:border-[var(--pear-accent-dim)] hover:text-[var(--pear-text)] disabled:cursor-not-allowed disabled:opacity-40"
title={
!activeRoot?.pathExists ? `Path not found: ${activeRoot?.path || activeProject.rootPath}`
: cliAvailability[cli] === false ? `${label} is not installed - run: ${SPAWN_AGENT_CLI_INSTALL_COMMANDS[cli]}`
: `Spawn ${label}`
}
>
<Icon className="h-4 w-4" />
<span>{spawningCli === cli ? 'Starting' : label}</span>
</button>
))}
</div>
) : (
<button
Expand Down
8 changes: 7 additions & 1 deletion src/renderer/src/lib/spawn-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { getAgentKey, useAgentStore } from '@/stores/agent-store'
import { useProjectStore, type Project, type ProjectRoot } from '@/stores/project-store'
import { useUIStore } from '@/stores/ui-store'

export type SpawnAgentCli = 'claude' | 'codex'
export type SpawnAgentCli = 'claude' | 'codex' | 'opencode'

export const SPAWN_AGENT_CLI_INSTALL_COMMANDS: Record<SpawnAgentCli, string> = {
claude: 'npm install -g @anthropic-ai/claude-code',
codex: 'npm install -g @openai/codex',
opencode: 'npm install -g opencode-ai'
}

function nextAgentName(cli: SpawnAgentCli, projectId: string, liveNames: string[]): string {
const existingNames = new Set([
Expand Down
1 change: 1 addition & 0 deletions src/shared/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,7 @@ export interface PearAPI {
unsubscribeAgentChannel: (projectId: string | undefined, name: string, channel: string) => Promise<void>
releaseAgent: (projectId: string | undefined, name: string) => Promise<void>
listAgents: (projectId?: string) => Promise<BrokerListAgent[]>
checkCliAvailable: (cli: string) => Promise<boolean>
listDetails: () => Promise<BrokerDetails[]>
listEvents: () => Promise<BrokerEventRecord[]>
shutdown: () => Promise<void>
Expand Down
Loading