Skip to content
48 changes: 48 additions & 0 deletions src/main/agent-host/agent-dashboard-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Agent Dashboard Event Bridge
*
* Translates AgentManagerEvent envelopes (from the utility-process agent host)
* into AGENT_DASHBOARD_EVENTS IPC events on the renderer-facing router.
*
* Event.data payloads are aligned by AgentManagerService to match each channel's
* Zod contract, so this module is a pure type → channel mapping.
*/

import { AGENT_DASHBOARD_EVENTS } from '@shared/ipc/agent-dashboard/channels';
import type { EventChannel, EventPayload } from '@shared/ipc-contract';

import type { AgentManagerEvent } from '../services/agent-manager/agent-manager-service';

type EmitFn = <T extends EventChannel>(channel: T, payload: EventPayload<T>) => void;

interface AgentManagerLike {
onEvent: (handler: (event: AgentManagerEvent) => void) => () => void;
}

interface RouterLike {
emit: EmitFn;
}

const CHANNEL_BY_TYPE: Record<AgentManagerEvent['type'], EventChannel> = {
'session.started': AGENT_DASHBOARD_EVENTS.SESSION.STARTED,
'session.ended': AGENT_DASHBOARD_EVENTS.SESSION.ENDED,
'status.changed': AGENT_DASHBOARD_EVENTS.SESSION['STATUS-CHANGED'],
'message.received': AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED,
'stream.event': AGENT_DASHBOARD_EVENTS.STREAM.EVENT,
};

export function forwardAgentManagerEvent(event: AgentManagerEvent, emit: EmitFn): void {
const channel = (CHANNEL_BY_TYPE as Partial<Record<string, EventChannel>>)[event.type];
if (!channel) return;
emit(channel, event.data as EventPayload<typeof channel>);
}

export function wireAgentDashboardBridge(
agentManager: AgentManagerLike,
router: RouterLike,
): () => void {
const emit = router.emit.bind(router);
return agentManager.onEvent((event) => {
forwardAgentManagerEvent(event, emit);
});
}
36 changes: 1 addition & 35 deletions src/main/agent-host/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import type {
ControlRequest,
ControlResponse,
} from './host-protocol';
import type { IpcRouter } from '../ipc/router';
import type {
AgentManagerEvent,
AgentManagerService,
Expand All @@ -30,47 +29,14 @@ let controlPort: Electron.MessagePortMain | null = null;
let eventPort: Electron.MessagePortMain | null = null;
let agentManager: AgentManagerService | null = null;

// ── Router Shim ─────────────────────────────────────────────

/**
* Creates a minimal IpcRouter shim for the utility process.
*
* The AgentManagerService calls `router.emit(channel, payload)` to
* send events to the renderer. In the utility process there is no
* BrowserWindow, so the shim is a no-op — event forwarding is handled
* separately via `agentManager.onEvent()` which posts to the eventPort.
*
* `handle` and `setBus` are stubs since no IPC handlers or command bus
* exist in the utility process.
*/
function createRouterShim(): IpcRouter {
// The AgentManagerService also emits events via its own emitEvent()
// which we subscribe to via onEvent(). That path forwards to eventPort.
// The router.emit() calls are therefore redundant here — we make them
// no-ops to avoid double-sending.
return {
emit: (_channel: string, _payload: unknown) => {
// No-op: events are forwarded via agentManager.onEvent() instead
},
handle: () => {
// No-op: utility process does not register IPC handlers
},
setBus: () => {
// No-op: no command bus in utility process
},
} as unknown as IpcRouter;
}

// ── Bootstrap ───────────────────────────────────────────────

function bootstrap(): void {
if (!controlPort || !eventPort) {
throw new Error('Cannot bootstrap: ports not received');
}

const routerShim = createRouterShim();

agentManager = createAgentManagerService({ router: routerShim });
agentManager = createAgentManagerService({});

// Capture eventPort in a local const so the closure doesn't need
// a non-null assertion on every call.
Expand Down
6 changes: 6 additions & 0 deletions src/main/bootstrap/service-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { WORKFLOW_ENGINE_EVENTS } from '@shared/ipc/workflow-engine/channels';
import { computeSchemaHash } from '@shared/replication/schema-hash';
import type { AppChannel } from '@shared/types/channel';

import { wireAgentDashboardBridge } from '../agent-host/agent-dashboard-bridge';
import { createOAuthManager } from '../auth/oauth-manager';
import { GITHUB_OAUTH_CONFIG } from '../auth/providers/github';
import { GOOGLE_OAUTH_CONFIG } from '../auth/providers/google';
Expand Down Expand Up @@ -210,6 +211,11 @@ export function createServiceRegistry(
const userSessionManager = createUserSessionManager();
const projectService = createProjectService({ db });

// Wire agent-host events onto the renderer-facing IPC router.
// Must run before any other agentHostClient.onEvent() subscriber so the
// renderer receives session lifecycle + chat + stream events.
wireAgentDashboardBridge(agentHostClient, router);

const commandBus = createCommandBus(db);
const busSessionManager = createBusSessionManager(db, agentHostClient);
busSessionManager.recoverInterrupted();
Expand Down
7 changes: 4 additions & 3 deletions src/main/features/agent-dashboard/agent-dashboard-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
* No business logic — just delegates to service methods and forwards events.
*
* Note: Agent manager events (session.started, message.received, etc.) are
* emitted directly by the AgentManagerService via the router it receives
* at construction. Only teammate events need forwarding here since the
* TeamWatcher service does not have a router reference.
* forwarded from the utility-process agent host onto the renderer-facing
* router by `wireAgentDashboardBridge` in `src/main/agent-host/agent-dashboard-bridge.ts`
* (wired in service-registry). Only teammate and QA events are emitted from
* this file.
*/

import { readFileSync } from 'node:fs';
Expand Down
62 changes: 25 additions & 37 deletions src/main/services/agent-manager/agent-manager-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

import { randomUUID } from 'node:crypto';

import { AGENT_DASHBOARD_EVENTS } from '@shared/ipc/agent-dashboard/channels';
import type {
AgentChatMessage,
AgentSession,
Expand All @@ -31,7 +30,6 @@ import { SubprocessStrategy } from './subprocess-strategy';
import type { AgentConnectionStrategy } from './agent-connection-strategy';
import type { ManagedProcess } from './process-manager';
import type { StreamJsonParser } from './stream-json-parser';
import type { IpcRouter } from '../../ipc/router';

// ── Configuration Types ──────────────────────────────────────

Expand Down Expand Up @@ -120,19 +118,19 @@ interface InternalSession {
// ── Factory ──────────────────────────────────────────────────

export interface AgentManagerDeps {
router: IpcRouter;
/** Optional connection strategy — defaults to SubprocessStrategy */
strategy?: AgentConnectionStrategy;
}

/**
* Create an AgentManagerService instance.
*
* Follows the ADC factory pattern: returns synchronous values,
* emits events via IPC router for renderer updates.
* Follows the ADC factory pattern: returns synchronous values.
* Emits AgentManagerEvents to internal subscribers via `eventHandlers`.
* The renderer-facing IPC bridge is wired separately — see
* `agent-host/agent-dashboard-bridge.ts`.
*/
export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerService {
const { router } = deps;
const processManager = createProcessManager();
// Strategy is available for future use — currently SubprocessStrategy delegates to processManager
const _strategy: AgentConnectionStrategy = deps.strategy ?? new SubprocessStrategy(processManager);
Expand All @@ -159,13 +157,11 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS
emitEvent({
type: 'status.changed',
sessionId: internal.session.id,
data: { previousStatus, newStatus },
});

router.emit(AGENT_DASHBOARD_EVENTS.SESSION['STATUS-CHANGED'], {
sessionId: internal.session.id,
previousStatus,
newStatus,
data: {
sessionId: internal.session.id,
previousStatus,
newStatus,
},
});
}

Expand Down Expand Up @@ -202,23 +198,19 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS
}

// Emit stream event to renderer
router.emit(AGENT_DASHBOARD_EVENTS.STREAM.EVENT, {
sessionId: internal.session.id,
event,
});

emitEvent({
type: 'stream.event',
sessionId: internal.session.id,
data: event,
data: {
sessionId: internal.session.id,
event,
},
});
}

function handleChatMessage(internal: InternalSession, message: AgentChatMessage): void {
internal.messages.push(message);

router.emit(AGENT_DASHBOARD_EVENTS.MESSAGE.RECEIVED, message);

emitEvent({
type: 'message.received',
sessionId: internal.session.id,
Expand Down Expand Up @@ -274,16 +266,15 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS
const exitStatus: AgentStatus = code === 0 ? 'completed' : 'failed';
updateSessionStatus(internal, exitStatus);

router.emit(AGENT_DASHBOARD_EVENTS.SESSION.ENDED, {
sessionId: internal.session.id,
status: exitStatus,
exitCode: code ?? undefined,
});

emitEvent({
type: 'session.ended',
sessionId: internal.session.id,
data: { code, signal },
data: {
sessionId: internal.session.id,
status: exitStatus,
exitCode: code ?? undefined,
signal: signal ?? undefined,
},
});
});
internal.cleanups.push(cleanExit);
Expand Down Expand Up @@ -347,7 +338,6 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS
sessions.set(session.id, internal);
wireProcessToParser(internal);

router.emit(AGENT_DASHBOARD_EVENTS.SESSION.STARTED, session);
emitEvent({
type: 'session.started',
sessionId: session.id,
Expand Down Expand Up @@ -405,7 +395,6 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS
`[AgentManager] Team Lead session started: ${session.id} (PID ${String(managedProcess.pid)})`,
);

router.emit(AGENT_DASHBOARD_EVENTS.SESSION.STARTED, session);
emitEvent({
type: 'session.started',
sessionId: session.id,
Expand Down Expand Up @@ -500,16 +489,15 @@ export function createAgentManagerService(deps: AgentManagerDeps): AgentManagerS

// Emit session.ended so subscribers (AssistantService, WorkspaceSessionManager) can clean up.
// The normal onExit handler was removed above, so we must emit manually.
router.emit(AGENT_DASHBOARD_EVENTS.SESSION.ENDED, {
sessionId: internal.session.id,
status: 'completed',
exitCode: undefined,
});

emitEvent({
type: 'session.ended',
sessionId: internal.session.id,
data: { code: 0, signal: null },
data: {
sessionId: internal.session.id,
status: 'completed' as const,
exitCode: undefined,
signal: undefined,
},
});

agentLogger.info(`[AgentManager] Session stopped: ${sessionId}`);
Expand Down
28 changes: 0 additions & 28 deletions src/renderer/features/workspace/api/useWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { useEffect } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';

import { WORKSPACE, WORKSPACE_EVENTS } from '@shared/ipc/workspace/channels';
import type { AgentChatMessage } from '@shared/types/agent-dashboard';

import { useIpcEvent } from '@renderer/shared/hooks';
import { ipc } from '@renderer/shared/lib/ipc';
Expand Down Expand Up @@ -73,36 +72,9 @@ export function useWorkspaceInit(projectId: string | null, projectPath: string |

/** Send a message to a workspace session. */
export function useWorkspaceSend() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ sessionId, message }: { sessionId: string; message: string }) =>
ipc(WORKSPACE.SEND.MESSAGE, { sessionId, message }),

onMutate: ({ sessionId, message }) => {
const optimisticMessage: AgentChatMessage = {
id: crypto.randomUUID(),
agentId: sessionId,
role: 'user',
content: [{ type: 'text', text: message }],
timestamp: new Date().toISOString(),
};

queryClient.setQueryData<AgentChatMessage[]>(
['agent-dashboard', 'messages', sessionId],
(old) => [...(old ?? []), optimisticMessage],
);

return { optimisticId: optimisticMessage.id };
},

onError: (_err, { sessionId }, context) => {
if (!context) return;
queryClient.setQueryData<AgentChatMessage[]>(
['agent-dashboard', 'messages', sessionId],
(old) => (old ?? []).filter((m) => m.id !== context.optimisticId),
);
},
});
}

Expand Down
Loading
Loading