diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index ded8c0c..7e66767 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -3,13 +3,14 @@ import { AgentService } from './agent.service.js'; import { ProviderService } from './provider.service.js'; import { RoutingService } from './routing.service.js'; import { ProvidersController } from './providers.controller.js'; +import { SessionsController } from './sessions.controller.js'; import { CoordModule } from '../coord/coord.module.js'; @Global() @Module({ imports: [CoordModule], providers: [ProviderService, RoutingService, AgentService], - controllers: [ProvidersController], + controllers: [ProvidersController, SessionsController], exports: [AgentService, ProviderService, RoutingService], }) export class AgentModule {} diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index fd78aee..0f948b5 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -12,6 +12,7 @@ import { CoordService } from '../coord/coord.service.js'; import { ProviderService } from './provider.service.js'; import { createBrainTools } from './tools/brain-tools.js'; import { createCoordTools } from './tools/coord-tools.js'; +import type { SessionInfoDto } from './session.dto.js'; export interface AgentSessionOptions { provider?: string; @@ -25,6 +26,9 @@ export interface AgentSession { piSession: PiAgentSession; listeners: Set<(event: AgentSessionEvent) => void>; unsubscribe: () => void; + createdAt: number; + promptCount: number; + channels: Set; } @Injectable() @@ -107,6 +111,9 @@ export class AgentService implements OnModuleDestroy { piSession, listeners, unsubscribe, + createdAt: Date.now(), + promptCount: 0, + channels: new Set(), }; this.sessions.set(sessionId, session); @@ -143,11 +150,53 @@ export class AgentService implements OnModuleDestroy { return this.sessions.get(sessionId); } + listSessions(): SessionInfoDto[] { + const now = Date.now(); + return Array.from(this.sessions.values()).map((s) => ({ + id: s.id, + provider: s.provider, + modelId: s.modelId, + createdAt: new Date(s.createdAt).toISOString(), + promptCount: s.promptCount, + channels: Array.from(s.channels), + durationMs: now - s.createdAt, + })); + } + + getSessionInfo(sessionId: string): SessionInfoDto | undefined { + const s = this.sessions.get(sessionId); + if (!s) return undefined; + return { + id: s.id, + provider: s.provider, + modelId: s.modelId, + createdAt: new Date(s.createdAt).toISOString(), + promptCount: s.promptCount, + channels: Array.from(s.channels), + durationMs: Date.now() - s.createdAt, + }; + } + + addChannel(sessionId: string, channel: string): void { + const session = this.sessions.get(sessionId); + if (session) { + session.channels.add(channel); + } + } + + removeChannel(sessionId: string, channel: string): void { + const session = this.sessions.get(sessionId); + if (session) { + session.channels.delete(channel); + } + } + async prompt(sessionId: string, message: string): Promise { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`No agent session found: ${sessionId}`); } + session.promptCount += 1; try { await session.piSession.prompt(message); } catch (err) { @@ -177,7 +226,13 @@ export class AgentService implements OnModuleDestroy { } catch (err) { this.logger.error(`Failed to unsubscribe session ${sessionId}`, String(err)); } + try { + session.piSession.dispose(); + } catch (err) { + this.logger.error(`Failed to dispose piSession for ${sessionId}`, String(err)); + } session.listeners.clear(); + session.channels.clear(); this.sessions.delete(sessionId); } diff --git a/apps/gateway/src/agent/session.dto.ts b/apps/gateway/src/agent/session.dto.ts new file mode 100644 index 0000000..32865c3 --- /dev/null +++ b/apps/gateway/src/agent/session.dto.ts @@ -0,0 +1,14 @@ +export interface SessionInfoDto { + id: string; + provider: string; + modelId: string; + createdAt: string; + promptCount: number; + channels: string[]; + durationMs: number; +} + +export interface SessionListDto { + sessions: SessionInfoDto[]; + total: number; +} diff --git a/apps/gateway/src/agent/sessions.controller.ts b/apps/gateway/src/agent/sessions.controller.ts new file mode 100644 index 0000000..4ac8579 --- /dev/null +++ b/apps/gateway/src/agent/sessions.controller.ts @@ -0,0 +1,39 @@ +import { + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { AgentService } from './agent.service.js'; + +@Controller('api/sessions') +@UseGuards(AuthGuard) +export class SessionsController { + constructor(private readonly agentService: AgentService) {} + + @Get() + list() { + const sessions = this.agentService.listSessions(); + return { sessions, total: sessions.length }; + } + + @Get(':id') + findOne(@Param('id') id: string) { + const info = this.agentService.getSessionInfo(id); + if (!info) throw new NotFoundException('Session not found'); + return info; + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async destroy(@Param('id') id: string) { + const info = this.agentService.getSessionInfo(id); + if (!info) throw new NotFoundException('Session not found'); + await this.agentService.destroySession(id); + } +} diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 34a61cb..53465b0 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -50,6 +50,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const session = this.clientSessions.get(client.id); if (session) { session.cleanup(); + this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`); this.clientSessions.delete(client.id); } } @@ -97,6 +98,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.clientSessions.set(client.id, { conversationId, cleanup }); + // Track channel connection + this.agentService.addChannel(conversationId, `websocket:${client.id}`); + // Send acknowledgment client.emit('message:ack', { conversationId, messageId: uuid() }); diff --git a/docs/TASKS.md b/docs/TASKS.md index df4a2d3..b1354e6 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -26,8 +26,8 @@ | P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 | | P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 | | P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 | -| P2-005 | in-progress | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | — | #23 | -| P2-006 | not-started | Phase 2 | Agent session management — tmux + monitoring | — | #24 | +| P2-005 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 | +| P2-006 | in-progress | Phase 2 | Agent session management — tmux + monitoring | — | #24 | | P2-007 | not-started | Phase 2 | Verify Phase 2 — multi-provider routing works | — | #25 | | P3-001 | not-started | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | — | #26 | | P3-002 | not-started | Phase 3 | Auth pages — login, registration, SSO redirect | — | #27 |