diff --git a/package.json b/package.json index f7b9f332..73ea1813 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,15 @@ "scripts": { "postinstall": "node scripts/install-relayfile-mount.mjs --optional", "relayfile-mount:install": "node scripts/install-relayfile-mount.mjs", + "relayfile-mount:install:release": "node scripts/install-relayfile-mount.mjs --release", "generate:mcp-resources": "node scripts/generate-mcp-extraResources.mjs", "verify:mcp-resources-drift": "node scripts/generate-mcp-extraResources.mjs --check", "dev": "electron-vite dev", "build": "npm run generate:mcp-resources && node scripts/install-relayfile-mount.mjs --optional && electron-vite build", "preview": "electron-vite preview", - "dist:mac": "npm run relayfile-mount:install && npm run build && electron-builder --mac --publish never", + "dist:mac": "npm run relayfile-mount:install:release && RELAYFILE_MOUNT_INSTALL_SOURCE=release npm run build && electron-builder --mac --publish never", "verify:mcp-spawn": "node scripts/verify-mcp-spawn.mjs", - "release:mac": "npm run relayfile-mount:install && npm run build && electron-builder --mac --publish always", + "release:mac": "npm run relayfile-mount:install:release && RELAYFILE_MOUNT_INSTALL_SOURCE=release npm run build && electron-builder --mac --publish always", "test": "node --experimental-strip-types --no-warnings --test 'src/main/__tests__/*.test.ts'", "personas:refresh": "npx --yes agentworkforce install @agentworkforce/persona-autonomous-actor --overwrite" }, diff --git a/scripts/install-relayfile-mount.mjs b/scripts/install-relayfile-mount.mjs index e378ca8b..bcc7a808 100644 --- a/scripts/install-relayfile-mount.mjs +++ b/scripts/install-relayfile-mount.mjs @@ -3,12 +3,16 @@ // app (see extraResources in electron-builder.yml) and found at runtime by // src/main/relayfile-mount-launcher.ts. // -// Resolution order: +// Default development resolution order: // 1. RELAYFILE_MOUNT_BIN — explicit path to a prebuilt binary // 2. RELAYFILE_DIST_DIR / sibling — local relayfile source checkout (../relayfile/dist) // 3. GitHub release download — relayfile-mount-- from the // AgentWorkforce/relayfile release matching the // installed @relayfile/sdk version +// +// Release mode (`--release` or RELAYFILE_MOUNT_INSTALL_SOURCE=release) ignores +// local binaries and installs from the matching GitHub release unless that +// release binary is already present. import { access, chmod, copyFile, mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises' import { constants, createWriteStream } from 'node:fs' import { dirname, join, resolve, sep } from 'node:path' @@ -17,6 +21,7 @@ import { pipeline } from 'node:stream/promises' import { createRequire } from 'node:module' const optional = process.argv.includes('--optional') +const releaseMode = process.argv.includes('--release') || process.env.RELAYFILE_MOUNT_INSTALL_SOURCE === 'release' const scriptDir = dirname(fileURLToPath(import.meta.url)) const pearRoot = resolve(scriptDir, '..') const require = createRequire(join(pearRoot, 'package.json')) @@ -68,15 +73,17 @@ function fail(message) { process.exit(1) } -async function installFromFile(source) { +async function installFromFile(source, marker = `local:${source}`) { await mkdir(dirname(target), { recursive: true }) if ((await realpath(source).catch(() => null)) === (await realpath(target).catch(() => null))) { await chmod(target, 0o755) + await writeFile(versionMarker, `${marker}\n`) console.log(`[relayfile-mount] already installed at ${target}`) return } await copyFile(source, target) await chmod(target, 0o755) + await writeFile(versionMarker, `${marker}\n`) console.log(`[relayfile-mount] installed ${source} -> ${target}`) } @@ -102,6 +109,35 @@ async function downloadRelease(version) { console.log(`[relayfile-mount] installed v${version} -> ${target}`) } +async function installedMarker() { + return (await canRead(target)) && (await canRead(versionMarker)) + ? (await readFile(versionMarker, 'utf8')).trim() + : null +} + +function requireSdkVersion() { + const version = sdkVersion() + if (!version) { + fail('could not determine @relayfile/sdk version (is it installed?)') + } + return version +} + +if (releaseMode) { + const version = requireSdkVersion() + const installedVersion = await installedMarker() + if (installedVersion === version) { + console.log(`[relayfile-mount] v${version} already installed at ${target}`) + process.exit(0) + } + try { + await downloadRelease(version) + } catch (error) { + fail(error instanceof Error ? error.message : String(error)) + } + process.exit(0) +} + // 1. Explicit binary path if (process.env.RELAYFILE_MOUNT_BIN) { const source = resolve(process.env.RELAYFILE_MOUNT_BIN) @@ -123,14 +159,8 @@ if (await canRead(localDist)) { } // 3. GitHub release matching the installed @relayfile/sdk version -const version = sdkVersion() -if (!version) { - fail('could not determine @relayfile/sdk version (is it installed?)') -} - -const installedVersion = (await canRead(target)) && (await canRead(versionMarker)) - ? (await readFile(versionMarker, 'utf8')).trim() - : null +const version = requireSdkVersion() +const installedVersion = await installedMarker() if (installedVersion === version) { console.log(`[relayfile-mount] v${version} already installed at ${target}`) process.exit(0) diff --git a/src/main/__tests__/integration-event-bridge.test.ts b/src/main/__tests__/integration-event-bridge.test.ts index 6cba3071..8234b11a 100644 --- a/src/main/__tests__/integration-event-bridge.test.ts +++ b/src/main/__tests__/integration-event-bridge.test.ts @@ -139,6 +139,7 @@ function makeHarness( sent: SentMessage[] listAgentsCalls: string[] deliveryConfirmationCalls: SentMessage[] + unsubscribedCount: () => number emit(event: ChangeEvent): Promise } { const subscribeCalls: SubscribeCall[] = [] @@ -147,6 +148,7 @@ function makeHarness( const listAgentsCalls: string[] = [] const deliveryConfirmationCalls: SentMessage[] = [] const subscriptions: Subscription[] = [] + let unsubscribedCount = 0 let activeSends = 0 const bridge = new IntegrationEventBridge({ @@ -156,7 +158,7 @@ function makeHarness( client: () => ({ subscribe(globs, onChange, options) { subscribeCalls.push({ globs: [...globs], onChange, options }) - const subscription = { unsubscribe: async () => undefined } + const subscription = { unsubscribe: async () => { unsubscribedCount += 1 } } subscriptions.push(subscription) return subscription }, @@ -205,7 +207,16 @@ function makeHarness( await waitForDispatcherTick() } - return { bridge, subscribeCalls, readFileCalls, sent, listAgentsCalls, deliveryConfirmationCalls, emit } + return { + bridge, + subscribeCalls, + readFileCalls, + sent, + listAgentsCalls, + deliveryConfirmationCalls, + unsubscribedCount: () => unsubscribedCount, + emit + } } beforeEach(() => { @@ -258,6 +269,35 @@ test('integration events route only to the targets for the matching integration assert.deepEqual(harness.sent.map((message) => message.input.to), ['alice', 'bob']) }) +test('can close stale project subscriptions while keeping the active project stream', async () => { + const harness = makeHarness() + + await harness.bridge.reconcile('stale-project', [ + integration({ + provider: 'slack', + integrationId: 'slack-1', + mountPaths: ['/slack/channels/C123'], + scope: { notifyAgents: ['alice'] } + }) + ]) + await harness.bridge.reconcile('active-project', [ + integration({ + provider: 'slack', + integrationId: 'slack-1', + mountPaths: ['/slack/channels/C123'], + scope: { notifyAgents: ['alice'] } + }) + ]) + + assert.equal(harness.subscribeCalls.length, 2) + + await harness.bridge.closeAllExcept('active-project') + assert.equal(harness.unsubscribedCount(), 1) + + await harness.bridge.close('active-project') + assert.equal(harness.unsubscribedCount(), 2) +}) + test('channel notification targets do not fall back to all project agents', async () => { const harness = makeHarness() diff --git a/src/main/__tests__/relayfile-mount-launcher-import.test.ts b/src/main/__tests__/relayfile-mount-launcher-import.test.ts new file mode 100644 index 00000000..823e251a --- /dev/null +++ b/src/main/__tests__/relayfile-mount-launcher-import.test.ts @@ -0,0 +1,47 @@ +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import assert from 'node:assert/strict' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const launcherPath = join(__dirname, '..', 'relayfile-mount-launcher.ts') +const packageJsonPath = join(__dirname, '..', '..', '..', 'package.json') +const installScriptPath = join(__dirname, '..', '..', '..', 'scripts', 'install-relayfile-mount.mjs') + +test('relayfile mount launcher imports the launcher from the SDK launcher entrypoint', async () => { + const source = await readFile(launcherPath, 'utf8') + + assert.match( + source, + /from ['"]@relayfile\/sdk\/mount-launcher['"]/, + 'createDefaultMountLauncher must come from @relayfile/sdk/mount-launcher' + ) + assert.doesNotMatch( + source, + /createDefaultMountLauncher[\s\S]*from ['"]@relayfile\/sdk['"]/, + 'the root @relayfile/sdk export does not expose createDefaultMountLauncher at runtime' + ) +}) + +test('mac release scripts install relayfile-mount from release assets only', async () => { + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as { + scripts: Record + } + const installScript = await readFile(installScriptPath, 'utf8') + + assert.equal( + packageJson.scripts['relayfile-mount:install:release'], + 'node scripts/install-relayfile-mount.mjs --release' + ) + for (const scriptName of ['dist:mac', 'release:mac']) { + const script = packageJson.scripts[scriptName] + assert.match(script, /relayfile-mount:install:release/) + assert.match(script, /RELAYFILE_MOUNT_INSTALL_SOURCE=release npm run build/) + } + assert.match(installScript, /const releaseMode = /) + assert.match( + installScript, + /if \(releaseMode\) \{[\s\S]*?await downloadRelease\(version\)[\s\S]*?process\.exit\(0\)/ + ) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 9f15021f..d62240d9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -268,8 +268,8 @@ function scheduleIntegrationAgentRefresh(projectId: string): void { const timer = setTimeout(() => { integrationAgentRefreshTimers.delete(projectId) - void integrationsManager.notifyAgentState(projectId).catch((error) => { - console.warn('[integrations] Failed to notify newly ready agent:', error instanceof Error ? error.message : String(error)) + void integrationsManager.refreshAgentState(projectId).catch((error) => { + console.warn('[integrations] Failed to refresh newly ready agent integration state:', error instanceof Error ? error.message : String(error)) }) }, 2_000) integrationAgentRefreshTimers.set(projectId, timer) diff --git a/src/main/integration-event-bridge.ts b/src/main/integration-event-bridge.ts index 80a4de6d..736378f9 100644 --- a/src/main/integration-event-bridge.ts +++ b/src/main/integration-event-bridge.ts @@ -1857,6 +1857,14 @@ export class IntegrationEventBridge { await Promise.all(Array.from(this.subscriptions.keys()).map((projectId) => this.close(projectId))) } + async closeAllExcept(projectIdToKeep: string): Promise { + await Promise.all( + Array.from(this.subscriptions.keys()) + .filter((projectId) => projectId !== projectIdToKeep) + .map((projectId) => this.close(projectId)) + ) + } + private async readEventContextPreview(projectId: string, event: ChangeEvent): Promise { if (event.type === 'file.deleted' || event.type === 'relayfile.changed.summary') return undefined const path = eventSummaryValue(event.resource.path) diff --git a/src/main/integrations.test.ts b/src/main/integrations.test.ts index 111f1d85..1a996dbb 100644 --- a/src/main/integrations.test.ts +++ b/src/main/integrations.test.ts @@ -100,6 +100,21 @@ const mock = vi.hoisted(() => { }) } + if (parsed.pathname === '/api/v1/workspaces/account-workspace-id/integrations') { + return jsonResponse({ + integrations: [ + { + provider: 'slack', + integrationId: 'slack-integration-1', + mountPaths: ['/slack/channels'], + scope: {}, + connectedAt: '2026-06-05T00:00:00.000Z', + ready: true + } + ] + }) + } + if (parsed.pathname === '/api/v1/workspaces/account-workspace-id/integrations/google-mail/options/channels') { return jsonResponse({ options: [ @@ -155,7 +170,8 @@ const mock = vi.hoisted(() => { }, integrationEventBridge: { reconcile: vi.fn(async () => undefined), - closeAll: vi.fn(async () => undefined) + closeAll: vi.fn(async () => undefined), + closeAllExcept: vi.fn(async () => undefined) }, cloudAgentManager: { updateMountPaths: vi.fn(async () => undefined) @@ -250,6 +266,8 @@ describe('IntegrationsManager', () => { mock.integrationMountManager.localPathsFor.mockClear() mock.integrationMountManager.stop.mockClear() mock.integrationEventBridge.reconcile.mockClear() + mock.integrationEventBridge.closeAll.mockClear() + mock.integrationEventBridge.closeAllExcept.mockClear() mock.cloudAgentManager.updateMountPaths.mockClear() mock.readFileCalls.splice(0) mock.relayClient.readFile.mockClear() @@ -391,6 +409,103 @@ describe('IntegrationsManager', () => { finishMountReconcile() }) + it('waits for a newly spawned agent before injecting integration guidance', async () => { + vi.useFakeTimers() + let listAttempts = 0 + mock.brokerManager.listAgents.mockImplementation(async () => { + listAttempts += 1 + return listAttempts === 1 + ? [] + : [{ name: 'claude-1', projectId: 'project-1' }] + }) + const manager = new IntegrationsManager() + + await manager.notifyAgentState('project-1') + expect(mock.brokerManager.sendMessage).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1_000) + expect(mock.brokerManager.listAgents).toHaveBeenCalledTimes(1) + expect(mock.brokerManager.sendMessage).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(500) + expect(mock.brokerManager.sendMessage).toHaveBeenCalledWith( + 'project-1', + expect.objectContaining({ + to: 'claude-1', + from: 'system', + text: expect.stringContaining(''), + data: { + kind: 'integrations-update', + system: true + } + }) + ) + }) + + it('does not dedupe integration guidance when the first agent wait times out empty', async () => { + vi.useFakeTimers() + mock.brokerManager.listAgents.mockResolvedValue([]) + const manager = new IntegrationsManager() + + await manager.notifyAgentState('project-1') + await vi.advanceTimersByTimeAsync(9_000) + expect(mock.brokerManager.sendMessage).not.toHaveBeenCalled() + + mock.brokerManager.listAgents.mockResolvedValue([{ name: 'claude-1', projectId: 'project-1' }]) + await manager.notifyAgentState('project-1') + await vi.advanceTimersByTimeAsync(1_000) + + expect(mock.brokerManager.sendMessage).toHaveBeenCalledWith( + 'project-1', + expect.objectContaining({ + to: 'claude-1', + from: 'system', + text: expect.stringContaining('') + }) + ) + }) + + it('subscribes integration events before waiting on local Relayfile mounts', async () => { + vi.useFakeTimers() + mock.setMountReconcilePromise(new Promise(() => undefined)) + mock.brokerManager.listAgents.mockResolvedValue([{ name: 'claude-1', projectId: 'project-1' }]) + const manager = new IntegrationsManager() + + await manager.notifyAgentState('project-1') + + expect(mock.integrationEventBridge.closeAllExcept).toHaveBeenCalledWith('project-1') + expect(mock.integrationEventBridge.reconcile).toHaveBeenCalledWith( + 'project-1', + expect.arrayContaining([ + expect.objectContaining({ + provider: 'slack', + integrationId: 'slack-integration-1' + }) + ]) + ) + + await vi.advanceTimersByTimeAsync(1_000) + expect(mock.brokerManager.sendMessage).toHaveBeenCalledWith( + 'project-1', + expect.objectContaining({ + to: 'claude-1', + from: 'system', + text: expect.stringContaining('') + }) + ) + }) + + it('builds initial spawn instructions from project integrations', () => { + const manager = new IntegrationsManager() + + const instructions = manager.initialSpawnInstructions('project-1') + + expect(instructions).toContain('Initial project integration context') + expect(instructions).toContain('') + expect(instructions).toContain('slack') + expect(instructions).toContain('.integrations/discovery/slack') + }) + it('reads a targeted remote Slack event record without reconciling local mounts', async () => { const manager = new IntegrationsManager() diff --git a/src/main/integrations.ts b/src/main/integrations.ts index 5187c029..9ccd2671 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -206,10 +206,17 @@ type IntegrationSystemMessageBridge = { ) => Promise } +type IntegrationSystemMessageOptions = { + waitForAgent?: boolean +} + const POLL_INTERVAL_MS = 2_000 const POLL_TIMEOUT_MS = 5 * 60_000 const CATALOG_CACHE_MS = 5 * 60_000 const SYSTEM_MESSAGE_DEBOUNCE_MS = 1_000 +const SYSTEM_MESSAGE_DEDUPE_MS = 30_000 +const SYSTEM_MESSAGE_AGENT_WAIT_TIMEOUT_MS = 8_000 +const SYSTEM_MESSAGE_AGENT_WAIT_INTERVAL_MS = 500 const LOCAL_MOUNT_CLOUD_HYDRATION_THROTTLE_MS = 30_000 const CATALOG_PATH = '/api/v1/integrations/catalog' const MAX_REMOTE_DIRECTORY_ENTRIES = 5_000 @@ -713,6 +720,7 @@ export class IntegrationsManager { private sessionMetadata = new Map() private pollTimers = new Map() private systemMessageTimers = new Map() + private recentlyInjectedSystemMessages = new Map() private catalogCache: IntegrationAdapter[] | null = null private catalogFetchedAt = 0 private localMountCloudHydrationStartedAt = 0 @@ -926,12 +934,18 @@ export class IntegrationsManager { } async startLocalMountDaemon(): Promise { - await this.syncLocalMounts() - await this.syncAllEventSubscriptions() + await this.syncActiveEventSubscriptions() + void this.syncLocalMounts().catch((error) => { + console.warn('[integrations] Failed to start local integration mounts:', toErrorMessage(error)) + }) } async notifyAgentState(projectId: string): Promise { - await this.syncAgentState(projectId, true) + await this.syncAgentState(projectId, true, { waitForAgent: true }) + } + + async refreshAgentState(projectId: string): Promise { + await this.syncAgentState(projectId, false) } async shutdownLocalMounts(): Promise { @@ -1138,6 +1152,14 @@ export class IntegrationsManager { await this.syncEventSubscriptions(projectId) } + async hydrateActiveProject(projectId: string | null): Promise { + if (!projectId) { + await integrationEventBridge.closeAll() + return + } + await this.hydrateProject(projectId) + } + private emit(event: IntegrationsEvent): void { for (const handler of Array.from(this.listeners)) { handler(event) @@ -1617,16 +1639,30 @@ export class IntegrationsManager { }) } - private async syncAgentState(projectId: string, notifyAgent: boolean): Promise { + private async syncAgentState( + projectId: string, + notifyAgent: boolean, + systemMessageOptions: IntegrationSystemMessageOptions = {} + ): Promise { let integrations = this.visibleIntegrationsForProject(projectId) - await this.syncLocalMounts() - integrations = await this.withLocalMountPaths(integrations) - await this.safeUpdateMountPaths(projectId, this.mountPathsFor(projectId)) const subscriptionsReady = await this.syncEventSubscriptions(projectId) if (notifyAgent) { - this.scheduleSystemMessage(projectId, this.buildSystemMessageSnippet(integrations, subscriptionsReady)) + this.scheduleSystemMessage( + projectId, + this.buildSystemMessageSnippet(integrations, subscriptionsReady), + systemMessageOptions + ) } + + void this.syncLocalIntegrationState(projectId).catch((error) => { + console.warn('[integrations] Failed to sync local integration state:', toErrorMessage(error)) + }) + } + + private async syncLocalIntegrationState(projectId: string): Promise { + await this.syncLocalMounts() + await this.safeUpdateMountPaths(projectId, this.mountPathsFor(projectId)) } private buildSystemMessageSnippet(integrations: ConnectedIntegration[], subscriptionsReady: boolean): string { @@ -1702,6 +1738,15 @@ export class IntegrationsManager { return lines.join('\n') } + initialSpawnInstructions(projectId: string): string | undefined { + const integrations = this.visibleIntegrationsForProject(projectId) + if (integrations.length === 0) return undefined + return [ + 'Initial project integration context. Treat this as setup context, not as the user task.', + this.buildSystemMessageSnippet(integrations, true) + ].join('\n') + } + private mountPathsForAgentWorkspace(integration: ConnectedIntegration): string[] { return dedupeStrings([ discoveryMountPathForProvider(integration.provider), @@ -1726,13 +1771,40 @@ export class IntegrationsManager { return canonicalMountPathsForConnectedIntegration(integration) } - private async safeInjectSystemMessage(projectId: string, message: string): Promise { + private async listSystemMessageAgents( + bridge: IntegrationSystemMessageBridge, + projectId: string, + waitForAgent: boolean + ): Promise> { + const startedAt = Date.now() + + for (;;) { + const agents = (await bridge.listAgents(projectId)) + .filter((agent) => agent.projectId === undefined || agent.projectId === projectId) + if (agents.length > 0 || !waitForAgent) return agents + if (Date.now() - startedAt >= SYSTEM_MESSAGE_AGENT_WAIT_TIMEOUT_MS) return agents + await new Promise((resolve) => setTimeout(resolve, SYSTEM_MESSAGE_AGENT_WAIT_INTERVAL_MS)) + } + } + + private async safeInjectSystemMessage( + projectId: string, + message: string, + options: IntegrationSystemMessageOptions = {} + ): Promise { try { + const messageKey = `${projectId}\0${message}` + const now = Date.now() + for (const [key, expiresAt] of Array.from(this.recentlyInjectedSystemMessages.entries())) { + if (expiresAt <= now) this.recentlyInjectedSystemMessages.delete(key) + } + if (this.recentlyInjectedSystemMessages.has(messageKey)) return + const bridge = brokerManager as unknown as IntegrationSystemMessageBridge - const agents = await bridge.listAgents(projectId) + const agents = await this.listSystemMessageAgents(bridge, projectId, options.waitForAgent === true) + if (agents.length === 0) return await Promise.all( agents - .filter((agent) => agent.projectId === undefined || agent.projectId === projectId) .map((agent) => { const input = { to: agent.name, @@ -1750,18 +1822,23 @@ export class IntegrationsManager { : bridge.sendMessage(projectId, input) }) ) + this.recentlyInjectedSystemMessages.set(messageKey, Date.now() + SYSTEM_MESSAGE_DEDUPE_MS) } catch (error) { console.warn('[integrations] Failed to inject integration system message:', toErrorMessage(error)) } } - private scheduleSystemMessage(projectId: string, message: string): void { + private scheduleSystemMessage( + projectId: string, + message: string, + options: IntegrationSystemMessageOptions = {} + ): void { const existing = this.systemMessageTimers.get(projectId) if (existing) clearTimeout(existing) const timer = setTimeout(() => { this.systemMessageTimers.delete(projectId) - void this.safeInjectSystemMessage(projectId, message) + void this.safeInjectSystemMessage(projectId, message, options) }, SYSTEM_MESSAGE_DEBOUNCE_MS) this.systemMessageTimers.set(projectId, timer) } @@ -1777,9 +1854,15 @@ export class IntegrationsManager { private async syncEventSubscriptions(projectId: string): Promise { try { + const activeProjectId = loadStore().activeProjectId + if (projectId !== activeProjectId) { + await integrationEventBridge.close(projectId) + return true + } + await integrationEventBridge.closeAllExcept(projectId) await integrationEventBridge.reconcile( projectId, - await this.withLocalMountPaths(this.visibleIntegrationsForProject(projectId)) + this.visibleIntegrationsForProject(projectId) ) return true } catch (error) { @@ -1788,8 +1871,13 @@ export class IntegrationsManager { } } - private async syncAllEventSubscriptions(): Promise { - await Promise.all(loadStore().projects.map((project) => this.syncEventSubscriptions(project.id))) + private async syncActiveEventSubscriptions(): Promise { + const activeProjectId = loadStore().activeProjectId + if (!activeProjectId) { + await integrationEventBridge.closeAll() + return + } + await this.syncEventSubscriptions(activeProjectId) } private async hydrateCloudIntegrationsForLocalMounts(): Promise { diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 87ef44d7..c679a969 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -125,8 +125,9 @@ export function registerIpcHandlers(): void { removeProject(id) }) - ipcMain.handle('project:set-active', (_, id: string | null) => { + ipcMain.handle('project:set-active', async (_, id: string | null) => { setActiveProject(id) + await integrationsManager.hydrateActiveProject(id) }) ipcMain.handle('project:update', (_, id: string, update: Record) => { @@ -210,7 +211,15 @@ export function registerIpcHandlers(): void { }) ipcMain.handle('broker:spawn-agent', async (_, projectId: string, input: SpawnPtyInput & { broker?: 'local' | 'cloud' }) => { - const result = await brokerManager.spawnAgent(projectId, input) + const integrationInstructions = integrationsManager.initialSpawnInstructions(projectId) + const result = await brokerManager.spawnAgent(projectId, integrationInstructions + ? { + ...input, + task: input.task?.trim() + ? `${integrationInstructions}\n\nUser task:\n${input.task.trim()}` + : integrationInstructions + } + : input) integrationEventBridge.invalidateProjectAgentCache(projectId) return result }) diff --git a/src/main/relayfile-mount-launcher.ts b/src/main/relayfile-mount-launcher.ts index 24d9c16f..3abeb791 100644 --- a/src/main/relayfile-mount-launcher.ts +++ b/src/main/relayfile-mount-launcher.ts @@ -1,11 +1,8 @@ import { accessSync, constants } from 'node:fs' import { join, resolve } from 'node:path' import { app } from 'electron' -import { - createDefaultMountLauncher, - type MountLauncher, - type MountLauncherStart -} from '@relayfile/sdk' +import { type MountLauncher, type MountLauncherStart } from '@relayfile/sdk' +import { createDefaultMountLauncher } from '@relayfile/sdk/mount-launcher' function canExecute(filePath: string | undefined): filePath is string { if (!filePath) return false