From 275b364e0bcb53794f2f02ae09ad5bdfc0487bb4 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 30 Jun 2026 17:39:46 -0700 Subject: [PATCH 1/2] feat(broker): let a project join an existing workspace by key Adds a "Close this workspace and join new one" control on the Agent Relay Status page: pastes a workspace key from another Pear instance, tears down this project's broker session, and restarts it pinned to that key so this Pear runs as a node on the existing workspace instead of creating its own. Co-Authored-By: Claude Sonnet 5 --- src/main/broker.ts | 31 +++- src/main/ipc-handlers.ts | 19 +++ src/preload/index.ts | 2 + .../components/broker/BrokerDetailsPage.tsx | 133 +++++++++++++++++- src/renderer/src/lib/ipc-mock.ts | 3 + src/shared/types/ipc.ts | 7 + 6 files changed, 192 insertions(+), 3 deletions(-) diff --git a/src/main/broker.ts b/src/main/broker.ts index 51db7ebe..9452bb1e 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -1339,7 +1339,8 @@ export class BrokerManager { cwd: string, name: string, win: BrowserWindow, - channels: string[] = [] + channels: string[] = [], + workspaceKey?: string ): Promise { const normalizedProjectId = projectId.trim() if (!normalizedProjectId) { @@ -1380,7 +1381,7 @@ export class BrokerManager { } const startBroker = async (): Promise => { - const explicitWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY?.trim() || undefined + const explicitWorkspaceKey = workspaceKey?.trim() || process.env.AGENT_RELAY_WORKSPACE_KEY?.trim() || undefined const existingClient = await this.connectExistingBroker(normalizedProjectId, cwd, explicitWorkspaceKey) if (existingClient) { const eventStreamGeneration = this.nextEventStreamGeneration() @@ -1617,6 +1618,32 @@ export class BrokerManager { return { removed } } + /** + * Tear down this project's current broker session(s) and start a fresh + * local broker pinned to an existing workspace key, so this Pear instance + * joins another instance's workspace instead of creating its own. + */ + async joinWorkspace( + projectId: string, + cwd: string, + name: string, + win: BrowserWindow, + channels: string[] = [], + workspaceKey: string + ): Promise { + const normalizedProjectId = projectId.trim() + if (!normalizedProjectId) { + throw new Error('Project id is required') + } + const trimmedKey = workspaceKey.trim() + if (!trimmedKey) { + throw new Error('Workspace key is required') + } + assertDirectory(cwd, 'Project path') + await this.shutdown(normalizedProjectId) + await this.start(normalizedProjectId, cwd, name, win, channels, trimmedKey) + } + /** * The local broker creates the project's relay workspace; its workspace_key * is what cloud sandbox brokers must join so local and cloud agents share diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index ed4f80ff..d600cd7a 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -650,6 +650,25 @@ export function registerIpcHandlers(): void { await brokerManager.syncChannels(projectId, channels) }) + ipcMain.handle('broker:join-workspace', async ( + event, + projectId: string, + cwd: string, + name: string, + channels: string[] | undefined, + workspaceKey: string + ) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) throw new Error('No window') + if (!isDirectory(cwd)) { + throw new Error(`Project path no longer exists: ${cwd}`) + } + await brokerManager.joinWorkspace(projectId, cwd, name, win, channels, workspaceKey) + void integrationsManager.notifyAgentState(projectId).catch((error) => { + console.warn('[integrations] Failed to notify agents after workspace join:', error instanceof Error ? error.message : String(error)) + }) + }) + ipcMain.handle('broker:auto-fix-runtime', async ( event, projectId: string, diff --git a/src/preload/index.ts b/src/preload/index.ts index 46b0906e..97c65f6e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -225,6 +225,8 @@ const api = { invoke('broker:start', projectId, cwd, name, channels), syncChannels: (projectId: string, channels: string[]) => invoke('broker:sync-channels', projectId, channels), + joinWorkspace: (projectId: string, cwd: string, name: string, channels: string[] | undefined, workspaceKey: string) => + invoke('broker:join-workspace', projectId, cwd, name, channels, workspaceKey), autoFixRuntime: ( projectId: string, cwd: string, diff --git a/src/renderer/src/components/broker/BrokerDetailsPage.tsx b/src/renderer/src/components/broker/BrokerDetailsPage.tsx index 3d2dfbd3..23f4ba36 100644 --- a/src/renderer/src/components/broker/BrokerDetailsPage.tsx +++ b/src/renderer/src/components/broker/BrokerDetailsPage.tsx @@ -9,6 +9,7 @@ import { Copy, KeyRound, List, + LogOut, MessageSquare, RefreshCw, Server, @@ -840,6 +841,82 @@ function BrokerEventFeed({ ) } +function JoinWorkspaceControl({ + project, + joining, + joinError, + onJoin +}: { + project?: Project + joining?: boolean + joinError?: string + onJoin: (project: Project, workspaceKey: string) => void +}): React.ReactNode { + const [open, setOpen] = useState(false) + const [key, setKey] = useState('') + + if (!project) return null + + const submit = (): void => { + const trimmed = key.trim() + if (!trimmed) return + onJoin(project, trimmed) + } + + if (!open) { + return ( + + ) + } + + return ( +
+

+ Paste the workspace key from another Pear instance's Agent Relay Status page. This closes the + current broker session for this project and reconnects it to that workspace. +

+
+ setKey(event.target.value)} + placeholder="Workspace key" + disabled={joining} + className="min-w-0 flex-1 rounded-md border border-[var(--pear-border-subtle)] bg-[var(--pear-bg)] px-2 py-1.5 font-mono text-[12px] text-[var(--pear-text)] outline-none focus:border-[var(--pear-accent-dim)]" + /> + + +
+ {joinError &&

{joinError}

} +
+ ) +} + function BrokerCard({ broker, projectName, @@ -848,7 +925,10 @@ function BrokerCard({ project, fixing, fixError, - onAutoFix + onAutoFix, + joining, + joinError, + onJoinWorkspace }: { broker: BrokerDetails projectName: string @@ -858,6 +938,9 @@ function BrokerCard({ fixing?: boolean fixError?: string onAutoFix?: (project: Project, entry: BrokerErrorEntry) => void + joining?: boolean + joinError?: string + onJoinWorkspace?: (project: Project, workspaceKey: string) => void }): React.ReactNode { const Icon = broker.kind === 'cloud' ? Cloud : Server @@ -904,6 +987,15 @@ function BrokerCard({
+ {project && onJoinWorkspace && ( + + )} +
@@ -975,6 +1067,8 @@ export function BrokerDetailsPage(): React.ReactNode { const [detailsError, setDetailsError] = useState(null) const [autoFixingProjectId, setAutoFixingProjectId] = useState(null) const [autoFixErrors, setAutoFixErrors] = useState>({}) + const [joiningProjectId, setJoiningProjectId] = useState(null) + const [joinErrors, setJoinErrors] = useState>({}) const pageProjectId = activeBrokerTabProjectId || activeProjectId || undefined const pageProjectName = pageProjectId ? getProjectName(projects, pageProjectId) : 'All projects' @@ -1087,6 +1181,40 @@ export function BrokerDetailsPage(): React.ReactNode { } }, [loadBrokerDetails]) + const handleJoinWorkspace = useCallback(async (project: Project, workspaceKey: string): Promise => { + const root = getDefaultProjectRoot(project) + if (!root?.pathExists) { + setJoinErrors((errors) => ({ + ...errors, + [project.id]: `Project path no longer exists: ${root?.path || project.rootPath}` + })) + return + } + + setJoiningProjectId(project.id) + setJoinErrors((errors) => { + const { [project.id]: _removed, ...remaining } = errors + return remaining + }) + try { + await pear.broker.joinWorkspace( + project.id, + root.path, + getProjectBrokerName(project), + project.channels, + workspaceKey + ) + await loadBrokerDetails() + } catch (err) { + setJoinErrors((errors) => ({ + ...errors, + [project.id]: err instanceof Error ? err.message : String(err) + })) + } finally { + setJoiningProjectId(null) + } + }, [loadBrokerDetails]) + useEffect(() => { void loadBrokerDetails() }, [loadBrokerDetails]) @@ -1145,6 +1273,9 @@ export function BrokerDetailsPage(): React.ReactNode { fixing={autoFixingProjectId === broker.projectId} fixError={autoFixErrors[broker.projectId]} onAutoFix={(project, entry) => void handleAutoFix(project, entry)} + joining={joiningProjectId === broker.projectId} + joinError={joinErrors[broker.projectId]} + onJoinWorkspace={(project, workspaceKey) => void handleJoinWorkspace(project, workspaceKey)} /> ))} {unmatchedProjectErrorGroups.map(([projectId, entries]) => ( diff --git a/src/renderer/src/lib/ipc-mock.ts b/src/renderer/src/lib/ipc-mock.ts index ee7d6ad0..c4f67710 100644 --- a/src/renderer/src/lib/ipc-mock.ts +++ b/src/renderer/src/lib/ipc-mock.ts @@ -875,6 +875,9 @@ export const pearMock: PearAPI = { const project = state.projects.find((entry) => entry.id === projectId) if (project) project.channels = Array.from(new Set(channels)) }, + joinWorkspace: async (projectId: string) => { + emitBrokerStatus({ projectId, status: 'connected' }) + }, autoFixRuntime: async () => ({ removed: [] }), connectCloud: async () => 'mock-cloud', spawnAgent: async (projectId: string, input: BrokerSpawnAgentInput): Promise => { diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index f4711a20..5fa0d63c 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -912,6 +912,13 @@ export interface PearAPI { broker: { start: (projectId: string, cwd: string, name: string, channels?: string[]) => Promise syncChannels: (projectId: string, channels: string[]) => Promise + joinWorkspace: ( + projectId: string, + cwd: string, + name: string, + channels: string[] | undefined, + workspaceKey: string + ) => Promise autoFixRuntime: ( projectId: string, cwd: string, From dd0d8057f81563cd703cf1f20971bfbedac01ffc Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 30 Jun 2026 18:04:44 -0700 Subject: [PATCH 2/2] fix(broker): address review feedback on join-workspace flow - Guard against joinWorkspace racing an in-flight broker:start for the same project, which could silently ignore the requested workspace key (cubic, codex). - Only notify agents after joinWorkspace actually started a session, matching broker:start's existing guard (cubic). - Auto-close and reset JoinWorkspaceControl on a successful join, and clear stale errors on reopen/cancel (gemini, cubic). - Support Enter-to-submit on the workspace key input (cubic). - Fix ipc-mock's joinWorkspace to match the real 5-arg signature and validate an empty key like the real implementation (cubic). Co-Authored-By: Claude Sonnet 5 --- src/main/broker.ts | 9 ++- src/main/ipc-handlers.ts | 10 +-- src/preload/index.ts | 2 +- .../components/broker/BrokerDetailsPage.tsx | 61 +++++++++++++++---- src/renderer/src/lib/ipc-mock.ts | 12 +++- src/shared/types/ipc.ts | 2 +- 6 files changed, 75 insertions(+), 21 deletions(-) diff --git a/src/main/broker.ts b/src/main/broker.ts index 9452bb1e..62e199ea 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -1630,7 +1630,7 @@ export class BrokerManager { win: BrowserWindow, channels: string[] = [], workspaceKey: string - ): Promise { + ): Promise { const normalizedProjectId = projectId.trim() if (!normalizedProjectId) { throw new Error('Project id is required') @@ -1640,8 +1640,13 @@ export class BrokerManager { throw new Error('Workspace key is required') } assertDirectory(cwd, 'Project path') + // A start already in flight for this project (e.g. the initial project + // open) would otherwise race start()'s in-flight-reuse path below and + // silently keep the broker on the old/first-requested key. + const inFlightStart = this.startPromises.get(normalizedProjectId) + if (inFlightStart) await inFlightStart.catch(() => undefined) await this.shutdown(normalizedProjectId) - await this.start(normalizedProjectId, cwd, name, win, channels, trimmedKey) + return this.start(normalizedProjectId, cwd, name, win, channels, trimmedKey) } /** diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index d600cd7a..876ce7f5 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -663,10 +663,12 @@ export function registerIpcHandlers(): void { if (!isDirectory(cwd)) { throw new Error(`Project path no longer exists: ${cwd}`) } - await brokerManager.joinWorkspace(projectId, cwd, name, win, channels, workspaceKey) - void integrationsManager.notifyAgentState(projectId).catch((error) => { - console.warn('[integrations] Failed to notify agents after workspace join:', error instanceof Error ? error.message : String(error)) - }) + const started = await brokerManager.joinWorkspace(projectId, cwd, name, win, channels, workspaceKey) + if (started) { + void integrationsManager.notifyAgentState(projectId).catch((error) => { + console.warn('[integrations] Failed to notify agents after workspace join:', error instanceof Error ? error.message : String(error)) + }) + } }) ipcMain.handle('broker:auto-fix-runtime', async ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 97c65f6e..cbf4f52d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -226,7 +226,7 @@ const api = { syncChannels: (projectId: string, channels: string[]) => invoke('broker:sync-channels', projectId, channels), joinWorkspace: (projectId: string, cwd: string, name: string, channels: string[] | undefined, workspaceKey: string) => - invoke('broker:join-workspace', projectId, cwd, name, channels, workspaceKey), + invoke('broker:join-workspace', projectId, cwd, name, channels, workspaceKey), autoFixRuntime: ( projectId: string, cwd: string, diff --git a/src/renderer/src/components/broker/BrokerDetailsPage.tsx b/src/renderer/src/components/broker/BrokerDetailsPage.tsx index 23f4ba36..dbf58070 100644 --- a/src/renderer/src/components/broker/BrokerDetailsPage.tsx +++ b/src/renderer/src/components/broker/BrokerDetailsPage.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Activity, AlertCircle, @@ -845,15 +845,29 @@ function JoinWorkspaceControl({ project, joining, joinError, - onJoin + onJoin, + onDismissError }: { project?: Project joining?: boolean joinError?: string onJoin: (project: Project, workspaceKey: string) => void + onDismissError?: (project: Project) => void }): React.ReactNode { const [open, setOpen] = useState(false) const [key, setKey] = useState('') + const wasJoining = useRef(false) + + useEffect(() => { + // Auto-close and reset once a join finishes without leaving an error — + // otherwise the form stays open with the submitted key and no feedback + // that the join actually succeeded. + if (wasJoining.current && !joining && !joinError) { + setOpen(false) + setKey('') + } + wasJoining.current = Boolean(joining) + }, [joining, joinError]) if (!project) return null @@ -863,11 +877,20 @@ function JoinWorkspaceControl({ onJoin(project, trimmed) } + const close = (): void => { + setOpen(false) + setKey('') + onDismissError?.(project) + } + if (!open) { return (
{joinError &&

{joinError}

} -
+ ) } @@ -928,7 +954,8 @@ function BrokerCard({ onAutoFix, joining, joinError, - onJoinWorkspace + onJoinWorkspace, + onDismissJoinError }: { broker: BrokerDetails projectName: string @@ -941,6 +968,7 @@ function BrokerCard({ joining?: boolean joinError?: string onJoinWorkspace?: (project: Project, workspaceKey: string) => void + onDismissJoinError?: (project: Project) => void }): React.ReactNode { const Icon = broker.kind === 'cloud' ? Cloud : Server @@ -993,6 +1021,7 @@ function BrokerCard({ joining={joining} joinError={joinError} onJoin={onJoinWorkspace} + onDismissError={onDismissJoinError} /> )} @@ -1215,6 +1244,13 @@ export function BrokerDetailsPage(): React.ReactNode { } }, [loadBrokerDetails]) + const handleDismissJoinError = useCallback((project: Project): void => { + setJoinErrors((errors) => { + const { [project.id]: _removed, ...remaining } = errors + return remaining + }) + }, []) + useEffect(() => { void loadBrokerDetails() }, [loadBrokerDetails]) @@ -1276,6 +1312,7 @@ export function BrokerDetailsPage(): React.ReactNode { joining={joiningProjectId === broker.projectId} joinError={joinErrors[broker.projectId]} onJoinWorkspace={(project, workspaceKey) => void handleJoinWorkspace(project, workspaceKey)} + onDismissJoinError={handleDismissJoinError} /> ))} {unmatchedProjectErrorGroups.map(([projectId, entries]) => ( diff --git a/src/renderer/src/lib/ipc-mock.ts b/src/renderer/src/lib/ipc-mock.ts index c4f67710..162ac205 100644 --- a/src/renderer/src/lib/ipc-mock.ts +++ b/src/renderer/src/lib/ipc-mock.ts @@ -875,8 +875,18 @@ export const pearMock: PearAPI = { const project = state.projects.find((entry) => entry.id === projectId) if (project) project.channels = Array.from(new Set(channels)) }, - joinWorkspace: async (projectId: string) => { + joinWorkspace: async ( + projectId: string, + _cwd: string, + _name: string, + _channels: string[] | undefined, + workspaceKey: string + ) => { + if (!workspaceKey.trim()) { + throw new Error('Workspace key is required') + } emitBrokerStatus({ projectId, status: 'connected' }) + return true }, autoFixRuntime: async () => ({ removed: [] }), connectCloud: async () => 'mock-cloud', diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 5fa0d63c..cb082d48 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -918,7 +918,7 @@ export interface PearAPI { name: string, channels: string[] | undefined, workspaceKey: string - ) => Promise + ) => Promise autoFixRuntime: ( projectId: string, cwd: string,