feat: communication spine — gateway, TUI, Discord

Gateway:
- Agent service wrapping Pi SDK createAgentSession (in-process)
- Chat WebSocket gateway (Socket.IO) streaming agent events
- Chat REST controller for synchronous requests
- NestJS module structure: AgentModule (global), ChatModule

CLI:
- Ink-based TUI client connecting to gateway via WebSocket
- Commander-based CLI with `mosaic tui` command
- Streaming message display with React components

Discord:
- Discord.js bot with mention-based activation + DM support
- Routes messages through gateway WebSocket
- Chunked response delivery (2000-char Discord limit)
- Single-guild binding for v0.1.0

Architecture: All channels → Gateway WebSocket → Pi SDK → LLM

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 20:24:33 -05:00
parent 888bc32be1
commit 98380e610d
14 changed files with 5009 additions and 10 deletions

View File

@@ -5,7 +5,7 @@
"main": "dist/main.js",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"dev": "tsx watch src/main.ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run"
@@ -14,12 +14,19 @@
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-fastify": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"@nestjs/websockets": "^11.0.0",
"@mariozechner/pi-coding-agent": "~0.57.1",
"fastify": "^5.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0"
"rxjs": "^7.8.0",
"socket.io": "^4.8.0",
"uuid": "^11.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AgentService } from './agent.service.js';
@Global()
@Module({
providers: [AgentService],
exports: [AgentService],
})
export class AgentModule {}

View File

@@ -0,0 +1,94 @@
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
import {
createAgentSession,
SessionManager,
type AgentSession as PiAgentSession,
type AgentSessionEvent,
} from '@mariozechner/pi-coding-agent';
export interface AgentSession {
id: string;
piSession: PiAgentSession;
listeners: Set<(event: AgentSessionEvent) => void>;
unsubscribe: () => void;
}
@Injectable()
export class AgentService implements OnModuleDestroy {
private readonly logger = new Logger(AgentService.name);
private readonly sessions = new Map<string, AgentSession>();
async createSession(sessionId: string): Promise<AgentSession> {
if (this.sessions.has(sessionId)) {
return this.sessions.get(sessionId)!;
}
this.logger.log(`Creating agent session: ${sessionId}`);
const { session: piSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
tools: [],
});
const listeners = new Set<(event: AgentSessionEvent) => void>();
const unsubscribe = piSession.subscribe((event) => {
for (const listener of listeners) {
try {
listener(event);
} catch (err) {
this.logger.error(`Event listener error in session ${sessionId}`, err);
}
}
});
const session: AgentSession = {
id: sessionId,
piSession,
listeners,
unsubscribe,
};
this.sessions.set(sessionId, session);
this.logger.log(`Agent session ${sessionId} ready`);
return session;
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
}
async prompt(sessionId: string, message: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
await session.piSession.prompt(message);
}
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
session.listeners.add(listener);
return () => session.listeners.delete(listener);
}
async destroySession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (session) {
this.logger.log(`Destroying agent session ${sessionId}`);
session.unsubscribe();
session.listeners.clear();
this.sessions.delete(sessionId);
}
}
async onModuleDestroy(): Promise<void> {
this.logger.log('Shutting down all agent sessions');
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
await Promise.allSettled(stops);
}
}

View File

@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health/health.controller.js';
import { AgentModule } from './agent/agent.module.js';
import { ChatModule } from './chat/chat.module.js';
@Module({
imports: [AgentModule, ChatModule],
controllers: [HealthController],
})
export class AppModule {}

View File

@@ -0,0 +1,55 @@
import { Controller, Post, Body, Logger } from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { AgentService } from '../agent/agent.service.js';
import { v4 as uuid } from 'uuid';
interface ChatRequest {
conversationId?: string;
content: string;
}
interface ChatResponse {
conversationId: string;
text: string;
}
@Controller('api/chat')
export class ChatController {
private readonly logger = new Logger(ChatController.name);
constructor(private readonly agentService: AgentService) {}
@Post()
async chat(@Body() body: ChatRequest): Promise<ChatResponse> {
const conversationId = body.conversationId ?? uuid();
let agentSession = this.agentService.getSession(conversationId);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId);
}
let responseText = '';
const done = new Promise<void>((resolve) => {
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
cleanup();
resolve();
}
});
setTimeout(() => {
cleanup();
resolve();
}, 120_000);
});
await this.agentService.prompt(conversationId, body.content);
await done;
return { conversationId, text: responseText };
}
}

View File

@@ -0,0 +1,139 @@
import { Logger } from '@nestjs/common';
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
type OnGatewayInit,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { AgentService } from '../agent/agent.service.js';
import { v4 as uuid } from 'uuid';
interface ChatMessage {
conversationId?: string;
content: string;
}
@WebSocketGateway({
cors: { origin: '*' },
namespace: '/chat',
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(ChatGateway.name);
private readonly clientSessions = new Map<
string,
{ conversationId: string; cleanup: () => void }
>();
constructor(private readonly agentService: AgentService) {}
afterInit(): void {
this.logger.log('Chat WebSocket gateway initialized');
}
handleConnection(client: Socket): void {
this.logger.log(`Client connected: ${client.id}`);
}
handleDisconnect(client: Socket): void {
this.logger.log(`Client disconnected: ${client.id}`);
const session = this.clientSessions.get(client.id);
if (session) {
session.cleanup();
this.clientSessions.delete(client.id);
}
}
@SubscribeMessage('message')
async handleMessage(
@ConnectedSocket() client: Socket,
@MessageBody() data: ChatMessage,
): Promise<void> {
const conversationId = data.conversationId ?? uuid();
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
// Ensure agent session exists for this conversation
let agentSession = this.agentService.getSession(conversationId);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId);
}
// Clean up any previous event subscription for this client
const existing = this.clientSessions.get(client.id);
if (existing && existing.conversationId !== conversationId) {
existing.cleanup();
}
// Subscribe to agent events and relay to client
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
this.relayEvent(client, conversationId, event);
});
this.clientSessions.set(client.id, { conversationId, cleanup });
// Send acknowledgment
client.emit('message:ack', { conversationId, messageId: uuid() });
// Dispatch to agent
try {
await this.agentService.prompt(conversationId, data.content);
} catch (err) {
this.logger.error(`Agent prompt failed: ${err}`);
client.emit('error', { conversationId, error: String(err) });
}
}
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
switch (event.type) {
case 'agent_start':
client.emit('agent:start', { conversationId });
break;
case 'agent_end':
client.emit('agent:end', { conversationId });
break;
case 'message_update': {
const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type === 'text_delta') {
client.emit('agent:text', {
conversationId,
text: assistantEvent.delta,
});
} else if (assistantEvent.type === 'thinking_delta') {
client.emit('agent:thinking', {
conversationId,
text: assistantEvent.delta,
});
}
break;
}
case 'tool_execution_start':
client.emit('agent:tool:start', {
conversationId,
toolCallId: event.toolCallId,
toolName: event.toolName,
});
break;
case 'tool_execution_end':
client.emit('agent:tool:end', {
conversationId,
toolCallId: event.toolCallId,
toolName: event.toolName,
isError: event.isError,
});
break;
}
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway.js';
import { ChatController } from './chat.controller.js';
@Module({
controllers: [ChatController],
providers: [ChatGateway],
})
export class ChatModule {}