From d3b3bb778007e1975a20085e74dcc2060297254c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 5 Jun 2026 19:33:31 +0200 Subject: [PATCH 1/6] Fix relayfile mount launcher packaging --- package.json | 5 +- scripts/install-relayfile-mount.mjs | 51 +++++++++++++++---- .../relayfile-mount-launcher-import.test.ts | 45 ++++++++++++++++ src/main/relayfile-mount-launcher.ts | 7 +-- 4 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 src/main/__tests__/relayfile-mount-launcher-import.test.ts 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..393b6455 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 always installs from the matching GitHub release unless +// the optional build-time check sees that the 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,8 @@ 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 forceReleaseDownload = releaseMode && !optional const scriptDir = dirname(fileURLToPath(import.meta.url)) const pearRoot = resolve(scriptDir, '..') const require = createRequire(join(pearRoot, 'package.json')) @@ -68,15 +74,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 +110,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 (!forceReleaseDownload && 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 +160,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__/relayfile-mount-launcher-import.test.ts b/src/main/__tests__/relayfile-mount-launcher-import.test.ts new file mode 100644 index 00000000..9a0b9848 --- /dev/null +++ b/src/main/__tests__/relayfile-mount-launcher-import.test.ts @@ -0,0 +1,45 @@ +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\) \{/) + assert.match(installScript, /await downloadRelease\(version\)/) +}) 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 From 334d8218da9c05f9d121c400cad6c7d4ebcfe27e Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Fri, 5 Jun 2026 17:45:39 +0000 Subject: [PATCH 2/6] chore: apply pr-reviewer fixes for #114 --- scripts/install-relayfile-mount.mjs | 7 +++---- src/main/__tests__/relayfile-mount-launcher-import.test.ts | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/install-relayfile-mount.mjs b/scripts/install-relayfile-mount.mjs index 393b6455..bcc7a808 100644 --- a/scripts/install-relayfile-mount.mjs +++ b/scripts/install-relayfile-mount.mjs @@ -11,8 +11,8 @@ // installed @relayfile/sdk version // // Release mode (`--release` or RELAYFILE_MOUNT_INSTALL_SOURCE=release) ignores -// local binaries and always installs from the matching GitHub release unless -// the optional build-time check sees that the release binary is already present. +// 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' @@ -22,7 +22,6 @@ 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 forceReleaseDownload = releaseMode && !optional const scriptDir = dirname(fileURLToPath(import.meta.url)) const pearRoot = resolve(scriptDir, '..') const require = createRequire(join(pearRoot, 'package.json')) @@ -127,7 +126,7 @@ function requireSdkVersion() { if (releaseMode) { const version = requireSdkVersion() const installedVersion = await installedMarker() - if (!forceReleaseDownload && installedVersion === version) { + if (installedVersion === version) { console.log(`[relayfile-mount] v${version} already installed at ${target}`) process.exit(0) } diff --git a/src/main/__tests__/relayfile-mount-launcher-import.test.ts b/src/main/__tests__/relayfile-mount-launcher-import.test.ts index 9a0b9848..823e251a 100644 --- a/src/main/__tests__/relayfile-mount-launcher-import.test.ts +++ b/src/main/__tests__/relayfile-mount-launcher-import.test.ts @@ -40,6 +40,8 @@ test('mac release scripts install relayfile-mount from release assets only', asy assert.match(script, /RELAYFILE_MOUNT_INSTALL_SOURCE=release npm run build/) } assert.match(installScript, /const releaseMode = /) - assert.match(installScript, /if \(releaseMode\) \{/) - assert.match(installScript, /await downloadRelease\(version\)/) + assert.match( + installScript, + /if \(releaseMode\) \{[\s\S]*?await downloadRelease\(version\)[\s\S]*?process\.exit\(0\)/ + ) }) From e68de1911ad30a77be61208c85d38208bbe89706 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 5 Jun 2026 19:49:32 +0200 Subject: [PATCH 3/6] Fix active integration event subscriptions --- .../integration-event-bridge.test.ts | 44 ++++++++++- src/main/integration-event-bridge.ts | 8 ++ src/main/integrations.test.ts | 33 ++++++++ src/main/integrations.ts | 78 ++++++++++++++++--- src/main/ipc-handlers.ts | 3 +- 5 files changed, 152 insertions(+), 14 deletions(-) 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/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..3a6a39a2 100644 --- a/src/main/integrations.test.ts +++ b/src/main/integrations.test.ts @@ -391,6 +391,39 @@ 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('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..677ef634 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -206,10 +206,16 @@ 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_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 @@ -927,11 +933,11 @@ export class IntegrationsManager { async startLocalMountDaemon(): Promise { await this.syncLocalMounts() - await this.syncAllEventSubscriptions() + await this.syncActiveEventSubscriptions() } async notifyAgentState(projectId: string): Promise { - await this.syncAgentState(projectId, true) + await this.syncAgentState(projectId, true, { waitForAgent: true }) } async shutdownLocalMounts(): Promise { @@ -1138,6 +1144,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,7 +1631,11 @@ 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) @@ -1625,7 +1643,11 @@ export class IntegrationsManager { const subscriptionsReady = await this.syncEventSubscriptions(projectId) if (notifyAgent) { - this.scheduleSystemMessage(projectId, this.buildSystemMessageSnippet(integrations, subscriptionsReady)) + this.scheduleSystemMessage( + projectId, + this.buildSystemMessageSnippet(integrations, subscriptionsReady), + systemMessageOptions + ) } } @@ -1726,13 +1748,32 @@ 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 bridge = brokerManager as unknown as IntegrationSystemMessageBridge - const agents = await bridge.listAgents(projectId) + const agents = await this.listSystemMessageAgents(bridge, projectId, options.waitForAgent === true) await Promise.all( agents - .filter((agent) => agent.projectId === undefined || agent.projectId === projectId) .map((agent) => { const input = { to: agent.name, @@ -1755,13 +1796,17 @@ export class IntegrationsManager { } } - 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,6 +1822,12 @@ 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)) @@ -1788,8 +1839,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..e00aa71c 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) => { From a050ed1b8a5a243405a9ff280b9a537e66e2cd7e Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 5 Jun 2026 20:02:10 +0200 Subject: [PATCH 4/6] Subscribe integration events before local mounts --- src/main/integrations.test.ts | 50 ++++++++++++++++++++++++++++++++++- src/main/integrations.ts | 28 ++++++++++++++++---- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/main/integrations.test.ts b/src/main/integrations.test.ts index 3a6a39a2..5fb237d8 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() @@ -424,6 +442,36 @@ describe('IntegrationsManager', () => { ) }) + 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('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 677ef634..fa9cf6d6 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -214,6 +214,7 @@ 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 @@ -719,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 @@ -932,8 +934,10 @@ export class IntegrationsManager { } async startLocalMountDaemon(): Promise { - await this.syncLocalMounts() await this.syncActiveEventSubscriptions() + void this.syncLocalMounts().catch((error) => { + console.warn('[integrations] Failed to start local integration mounts:', toErrorMessage(error)) + }) } async notifyAgentState(projectId: string): Promise { @@ -1637,9 +1641,6 @@ export class IntegrationsManager { 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) { @@ -1649,6 +1650,15 @@ export class IntegrationsManager { 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 { @@ -1770,6 +1780,14 @@ export class IntegrationsManager { 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 + this.recentlyInjectedSystemMessages.set(messageKey, now + SYSTEM_MESSAGE_DEDUPE_MS) + const bridge = brokerManager as unknown as IntegrationSystemMessageBridge const agents = await this.listSystemMessageAgents(bridge, projectId, options.waitForAgent === true) await Promise.all( @@ -1830,7 +1848,7 @@ export class IntegrationsManager { await integrationEventBridge.closeAllExcept(projectId) await integrationEventBridge.reconcile( projectId, - await this.withLocalMountPaths(this.visibleIntegrationsForProject(projectId)) + this.visibleIntegrationsForProject(projectId) ) return true } catch (error) { From 8bcf95b0f46e6d4f4b206f141713796ebf6153cd Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Fri, 5 Jun 2026 18:07:42 +0000 Subject: [PATCH 5/6] chore: apply pr-reviewer fixes for #114 --- src/main/integrations.test.ts | 23 +++++++++++++++++++++++ src/main/integrations.ts | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/integrations.test.ts b/src/main/integrations.test.ts index 5fb237d8..f6b0642c 100644 --- a/src/main/integrations.test.ts +++ b/src/main/integrations.test.ts @@ -442,6 +442,29 @@ describe('IntegrationsManager', () => { ) }) + 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)) diff --git a/src/main/integrations.ts b/src/main/integrations.ts index fa9cf6d6..d981fc48 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -1786,10 +1786,10 @@ export class IntegrationsManager { if (expiresAt <= now) this.recentlyInjectedSystemMessages.delete(key) } if (this.recentlyInjectedSystemMessages.has(messageKey)) return - this.recentlyInjectedSystemMessages.set(messageKey, now + SYSTEM_MESSAGE_DEDUPE_MS) const bridge = brokerManager as unknown as IntegrationSystemMessageBridge const agents = await this.listSystemMessageAgents(bridge, projectId, options.waitForAgent === true) + if (agents.length === 0) return await Promise.all( agents .map((agent) => { @@ -1809,6 +1809,7 @@ 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)) } From 6620cee39a9a40ebb2b94c6ea1395d3d01926e46 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 5 Jun 2026 20:11:49 +0200 Subject: [PATCH 6/6] Seed integration context on agent spawn --- src/main/index.ts | 4 ++-- src/main/integrations.test.ts | 11 +++++++++++ src/main/integrations.ts | 13 +++++++++++++ src/main/ipc-handlers.ts | 10 +++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) 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/integrations.test.ts b/src/main/integrations.test.ts index f6b0642c..1a996dbb 100644 --- a/src/main/integrations.test.ts +++ b/src/main/integrations.test.ts @@ -495,6 +495,17 @@ describe('IntegrationsManager', () => { ) }) + 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 d981fc48..9ccd2671 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -944,6 +944,10 @@ export class IntegrationsManager { await this.syncAgentState(projectId, true, { waitForAgent: true }) } + async refreshAgentState(projectId: string): Promise { + await this.syncAgentState(projectId, false) + } + async shutdownLocalMounts(): Promise { for (const timer of this.systemMessageTimers.values()) clearTimeout(timer) this.systemMessageTimers.clear() @@ -1734,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), diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index e00aa71c..c679a969 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -211,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 })