feat(cli): command architecture — agents, missions, gateway-aware prdy (#158)
Some checks failed
ci/woodpecker/push/ci Pipeline failed

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #158.
This commit is contained in:
2026-03-15 23:10:23 +00:00
committed by jason.woltje
parent 82c10a7b33
commit 4da255bf04
28 changed files with 1747 additions and 394 deletions

View File

@@ -1,4 +1,4 @@
import { ForbiddenException } from '@nestjs/common';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ConversationsController } from '../conversations/conversations.controller.js';
import { MissionsController } from '../missions/missions.controller.js';
@@ -25,12 +25,21 @@ function createBrain() {
},
missions: {
findAll: vi.fn(),
findAllByUser: vi.fn(),
findById: vi.fn(),
findByIdAndUser: vi.fn(),
findByProject: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
},
missionTasks: {
findByMissionAndUser: vi.fn(),
findByIdAndUser: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
},
tasks: {
findAll: vi.fn(),
findById: vi.fn(),
@@ -65,14 +74,14 @@ describe('Resource ownership checks', () => {
);
});
it('forbids access to a mission owned by another project owner', async () => {
it('forbids access to a mission owned by another user', async () => {
const brain = createBrain();
brain.missions.findById.mockResolvedValue({ id: 'mission-1', projectId: 'project-1' });
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
// findByIdAndUser returns undefined when the mission doesn't belong to the user
brain.missions.findByIdAndUser.mockResolvedValue(undefined);
const controller = new MissionsController(brain as never);
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
ForbiddenException,
NotFoundException,
);
});

View File

@@ -0,0 +1,97 @@
import {
IsArray,
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
const agentStatuses = ['idle', 'active', 'error', 'offline'] as const;
export class CreateAgentConfigDto {
@IsString()
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(255)
provider!: string;
@IsString()
@MaxLength(255)
model!: string;
@IsOptional()
@IsIn(agentStatuses)
status?: 'idle' | 'active' | 'error' | 'offline';
@IsOptional()
@IsUUID()
projectId?: string;
@IsOptional()
@IsString()
@MaxLength(50_000)
systemPrompt?: string;
@IsOptional()
@IsArray()
allowedTools?: string[];
@IsOptional()
@IsArray()
skills?: string[];
@IsOptional()
@IsBoolean()
isSystem?: boolean;
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}
export class UpdateAgentConfigDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MaxLength(255)
provider?: string;
@IsOptional()
@IsString()
@MaxLength(255)
model?: string;
@IsOptional()
@IsIn(agentStatuses)
status?: 'idle' | 'active' | 'error' | 'offline';
@IsOptional()
@IsUUID()
projectId?: string | null;
@IsOptional()
@IsString()
@MaxLength(50_000)
systemPrompt?: string | null;
@IsOptional()
@IsArray()
allowedTools?: string[] | null;
@IsOptional()
@IsArray()
skills?: string[] | null;
@IsOptional()
@IsObject()
config?: Record<string, unknown> | null;
}

View File

@@ -0,0 +1,84 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { CreateAgentConfigDto, UpdateAgentConfigDto } from './agent-config.dto.js';
@Controller('api/agents')
@UseGuards(AuthGuard)
export class AgentConfigsController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list(@CurrentUser() user: { id: string; role?: string }) {
return this.brain.agents.findAccessible(user.id);
}
@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const agent = await this.brain.agents.findById(id);
if (!agent) throw new NotFoundException('Agent not found');
if (!agent.isSystem && agent.ownerId !== user.id) {
throw new ForbiddenException('Agent does not belong to the current user');
}
return agent;
}
@Post()
async create(@Body() dto: CreateAgentConfigDto, @CurrentUser() user: { id: string }) {
return this.brain.agents.create({
...dto,
ownerId: user.id,
isSystem: false,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateAgentConfigDto,
@CurrentUser() user: { id: string; role?: string },
) {
const agent = await this.brain.agents.findById(id);
if (!agent) throw new NotFoundException('Agent not found');
if (agent.isSystem && user.role !== 'admin') {
throw new ForbiddenException('Only admins can update system agents');
}
if (!agent.isSystem && agent.ownerId !== user.id) {
throw new ForbiddenException('Agent does not belong to the current user');
}
const updated = await this.brain.agents.update(id, dto);
if (!updated) throw new NotFoundException('Agent not found');
return updated;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string, @CurrentUser() user: { id: string; role?: string }) {
const agent = await this.brain.agents.findById(id);
if (!agent) throw new NotFoundException('Agent not found');
if (agent.isSystem) {
throw new ForbiddenException('Cannot delete system agents');
}
if (agent.ownerId !== user.id) {
throw new ForbiddenException('Agent does not belong to the current user');
}
const deleted = await this.brain.agents.remove(id);
if (!deleted) throw new NotFoundException('Agent not found');
}
}

View File

@@ -5,6 +5,7 @@ import { RoutingService } from './routing.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
import { ProvidersController } from './providers.controller.js';
import { SessionsController } from './sessions.controller.js';
import { AgentConfigsController } from './agent-configs.controller.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
@@ -13,7 +14,7 @@ import { SkillsModule } from '../skills/skills.module.js';
@Module({
imports: [CoordModule, McpClientModule, SkillsModule],
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
controllers: [ProvidersController, SessionsController],
controllers: [ProvidersController, SessionsController, AgentConfigsController],
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
})
export class AgentModule {}

View File

@@ -49,6 +49,12 @@ export interface AgentSessionOptions {
allowedTools?: string[];
/** Whether the requesting user has admin privileges. Controls default tool access. */
isAdmin?: boolean;
/**
* DB agent config ID. When provided, loads agent config from DB and merges
* provider, model, systemPrompt, and allowedTools. Explicit call-site options
* take precedence over config values.
*/
agentConfigId?: string;
}
export interface AgentSession {
@@ -146,16 +152,39 @@ export class AgentService implements OnModuleDestroy {
sessionId: string,
options?: AgentSessionOptions,
): Promise<AgentSession> {
const model = this.resolveModel(options);
// Merge DB agent config when agentConfigId is provided
let mergedOptions = options;
if (options?.agentConfigId) {
const agentConfig = await this.brain.agents.findById(options.agentConfigId);
if (agentConfig) {
mergedOptions = {
provider: options.provider ?? agentConfig.provider,
modelId: options.modelId ?? agentConfig.model,
systemPrompt: options.systemPrompt ?? agentConfig.systemPrompt ?? undefined,
allowedTools: options.allowedTools ?? agentConfig.allowedTools ?? undefined,
sandboxDir: options.sandboxDir,
isAdmin: options.isAdmin,
agentConfigId: options.agentConfigId,
};
this.logger.log(
`Merged agent config "${agentConfig.name}" (${agentConfig.id}) into session ${sessionId}`,
);
}
}
const model = this.resolveModel(mergedOptions);
const providerName = model?.provider ?? 'default';
const modelId = model?.id ?? 'default';
// Resolve sandbox directory: option > env var > process.cwd()
const sandboxDir =
options?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
mergedOptions?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
// Resolve allowed tool set
const allowedTools = this.resolveAllowedTools(options?.isAdmin ?? false, options?.allowedTools);
const allowedTools = this.resolveAllowedTools(
mergedOptions?.isAdmin ?? false,
mergedOptions?.allowedTools,
);
this.logger.log(
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
@@ -194,7 +223,8 @@ export class AgentService implements OnModuleDestroy {
}
// Build system prompt: platform prompt + skill additions appended
const platformPrompt = options?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
const platformPrompt =
mergedOptions?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
const appendSystemPrompt =
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;

View File

@@ -28,4 +28,8 @@ export class ChatSocketMessageDto {
@IsString()
@MaxLength(255)
modelId?: string;
@IsOptional()
@IsUUID()
agentId?: string;
}

View File

@@ -83,6 +83,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
agentSession = await this.agentService.createSession(conversationId, {
provider: data.provider,
modelId: data.modelId,
agentConfigId: data.agentId,
});
}
} catch (err) {

View File

@@ -1,30 +1,17 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import fs from 'node:fs';
import path from 'node:path';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { CoordService } from './coord.service.js';
import type {
CreateDbMissionDto,
UpdateDbMissionDto,
CreateMissionTaskDto,
UpdateMissionTaskDto,
} from './coord.dto.js';
/** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */
function findMonorepoRoot(start: string): string {
@@ -57,13 +44,15 @@ function resolveAndValidatePath(raw: string | undefined): string {
return resolved;
}
/**
* File-based coord endpoints for agent tool consumption.
* DB-backed mission CRUD has moved to MissionsController at /api/missions.
*/
@Controller('api/coord')
@UseGuards(AuthGuard)
export class CoordController {
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
// ── File-based coord endpoints (legacy) ──
@Get('status')
async missionStatus(@Query('projectPath') projectPath?: string) {
const resolvedPath = resolveAndValidatePath(projectPath);
@@ -85,121 +74,4 @@ export class CoordController {
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
return detail;
}
// ── DB-backed mission endpoints ──
@Get('missions')
async listDbMissions(@CurrentUser() user: { id: string }) {
return this.coordService.getMissionsByUser(user.id);
}
@Get('missions/:id')
async getDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const mission = await this.coordService.getMissionByIdAndUser(id, user.id);
if (!mission) throw new NotFoundException('Mission not found');
return mission;
}
@Post('missions')
async createDbMission(@Body() dto: CreateDbMissionDto, @CurrentUser() user: { id: string }) {
return this.coordService.createDbMission({
name: dto.name,
description: dto.description,
projectId: dto.projectId,
userId: user.id,
phase: dto.phase,
milestones: dto.milestones,
config: dto.config,
status: dto.status,
});
}
@Patch('missions/:id')
async updateDbMission(
@Param('id') id: string,
@Body() dto: UpdateDbMissionDto,
@CurrentUser() user: { id: string },
) {
const mission = await this.coordService.updateDbMission(id, user.id, dto);
if (!mission) throw new NotFoundException('Mission not found');
return mission;
}
@Delete('missions/:id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const deleted = await this.coordService.deleteDbMission(id, user.id);
if (!deleted) throw new NotFoundException('Mission not found');
}
// ── DB-backed mission task endpoints ──
@Get('missions/:missionId/mission-tasks')
async listMissionTasks(
@Param('missionId') missionId: string,
@CurrentUser() user: { id: string },
) {
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
return this.coordService.getMissionTasksByMissionAndUser(missionId, user.id);
}
@Get('missions/:missionId/mission-tasks/:taskId')
async getMissionTask(
@Param('missionId') missionId: string,
@Param('taskId') taskId: string,
@CurrentUser() user: { id: string },
) {
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
const task = await this.coordService.getMissionTaskByIdAndUser(taskId, user.id);
if (!task) throw new NotFoundException('Mission task not found');
return task;
}
@Post('missions/:missionId/mission-tasks')
async createMissionTask(
@Param('missionId') missionId: string,
@Body() dto: CreateMissionTaskDto,
@CurrentUser() user: { id: string },
) {
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
return this.coordService.createMissionTask({
missionId,
taskId: dto.taskId,
userId: user.id,
status: dto.status,
description: dto.description,
notes: dto.notes,
pr: dto.pr,
});
}
@Patch('missions/:missionId/mission-tasks/:taskId')
async updateMissionTask(
@Param('missionId') missionId: string,
@Param('taskId') taskId: string,
@Body() dto: UpdateMissionTaskDto,
@CurrentUser() user: { id: string },
) {
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
const updated = await this.coordService.updateMissionTask(taskId, user.id, dto);
if (!updated) throw new NotFoundException('Mission task not found');
return updated;
}
@Delete('missions/:missionId/mission-tasks/:taskId')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteMissionTask(
@Param('missionId') missionId: string,
@Param('taskId') taskId: string,
@CurrentUser() user: { id: string },
) {
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
const deleted = await this.coordService.deleteMissionTask(taskId, user.id);
if (!deleted) throw new NotFoundException('Mission task not found');
}
}

View File

@@ -1,6 +1,4 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { Injectable, Logger } from '@nestjs/common';
import {
loadMission,
getMissionStatus,
@@ -14,12 +12,14 @@ import {
import { promises as fs } from 'node:fs';
import path from 'node:path';
/**
* File-based coord operations for agent tool consumption.
* DB-backed mission CRUD is handled directly by MissionsController via Brain repos.
*/
@Injectable()
export class CoordService {
private readonly logger = new Logger(CoordService.name);
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
async loadMission(projectPath: string): Promise<Mission | null> {
try {
return await loadMission(projectPath);
@@ -74,68 +74,4 @@ export class CoordService {
return [];
}
}
// ── DB-backed methods for multi-tenant mission management ──
async getMissionsByUser(userId: string) {
return this.brain.missions.findAllByUser(userId);
}
async getMissionByIdAndUser(id: string, userId: string) {
return this.brain.missions.findByIdAndUser(id, userId);
}
async getMissionsByProjectAndUser(projectId: string, userId: string) {
return this.brain.missions.findByProjectAndUser(projectId, userId);
}
async createDbMission(data: Parameters<Brain['missions']['create']>[0]) {
return this.brain.missions.create(data);
}
async updateDbMission(
id: string,
userId: string,
data: Parameters<Brain['missions']['update']>[1],
) {
const existing = await this.brain.missions.findByIdAndUser(id, userId);
if (!existing) return null;
return this.brain.missions.update(id, data);
}
async deleteDbMission(id: string, userId: string) {
const existing = await this.brain.missions.findByIdAndUser(id, userId);
if (!existing) return false;
return this.brain.missions.remove(id);
}
// ── DB-backed methods for mission tasks (coord tracking) ──
async getMissionTasksByMissionAndUser(missionId: string, userId: string) {
return this.brain.missionTasks.findByMissionAndUser(missionId, userId);
}
async getMissionTaskByIdAndUser(id: string, userId: string) {
return this.brain.missionTasks.findByIdAndUser(id, userId);
}
async createMissionTask(data: Parameters<Brain['missionTasks']['create']>[0]) {
return this.brain.missionTasks.create(data);
}
async updateMissionTask(
id: string,
userId: string,
data: Parameters<Brain['missionTasks']['update']>[1],
) {
const existing = await this.brain.missionTasks.findByIdAndUser(id, userId);
if (!existing) return null;
return this.brain.missionTasks.update(id, data);
}
async deleteMissionTask(id: string, userId: string) {
const existing = await this.brain.missionTasks.findByIdAndUser(id, userId);
if (!existing) return false;
return this.brain.missionTasks.remove(id);
}
}

View File

@@ -2,7 +2,6 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
@@ -17,33 +16,42 @@ import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { assertOwner } from '../auth/resource-ownership.js';
import { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
import {
CreateMissionDto,
UpdateMissionDto,
CreateMissionTaskDto,
UpdateMissionTaskDto,
} from './missions.dto.js';
@Controller('api/missions')
@UseGuards(AuthGuard)
export class MissionsController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
// ── Missions CRUD (user-scoped) ──
@Get()
async list() {
return this.brain.missions.findAll();
async list(@CurrentUser() user: { id: string }) {
return this.brain.missions.findAllByUser(user.id);
}
@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.getOwnedMission(id, user.id);
const mission = await this.brain.missions.findByIdAndUser(id, user.id);
if (!mission) throw new NotFoundException('Mission not found');
return mission;
}
@Post()
async create(@Body() dto: CreateMissionDto, @CurrentUser() user: { id: string }) {
if (dto.projectId) {
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
}
return this.brain.missions.create({
name: dto.name,
description: dto.description,
projectId: dto.projectId,
userId: user.id,
phase: dto.phase,
milestones: dto.milestones,
config: dto.config,
status: dto.status,
});
}
@@ -54,10 +62,8 @@ export class MissionsController {
@Body() dto: UpdateMissionDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedMission(id, user.id);
if (dto.projectId) {
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
}
const existing = await this.brain.missions.findByIdAndUser(id, user.id);
if (!existing) throw new NotFoundException('Mission not found');
const mission = await this.brain.missions.update(id, dto);
if (!mission) throw new NotFoundException('Mission not found');
return mission;
@@ -66,33 +72,81 @@ export class MissionsController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
await this.getOwnedMission(id, user.id);
const existing = await this.brain.missions.findByIdAndUser(id, user.id);
if (!existing) throw new NotFoundException('Mission not found');
const deleted = await this.brain.missions.remove(id);
if (!deleted) throw new NotFoundException('Mission not found');
}
private async getOwnedMission(id: string, userId: string) {
const mission = await this.brain.missions.findById(id);
// ── Mission Tasks sub-routes ──
@Get(':missionId/tasks')
async listTasks(@Param('missionId') missionId: string, @CurrentUser() user: { id: string }) {
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
await this.getOwnedProject(mission.projectId, userId, 'Mission');
return mission;
return this.brain.missionTasks.findByMissionAndUser(missionId, user.id);
}
private async getOwnedProject(
projectId: string | null | undefined,
userId: string,
resourceName: string,
@Get(':missionId/tasks/:taskId')
async getTask(
@Param('missionId') missionId: string,
@Param('taskId') taskId: string,
@CurrentUser() user: { id: string },
) {
if (!projectId) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
const task = await this.brain.missionTasks.findByIdAndUser(taskId, user.id);
if (!task) throw new NotFoundException('Mission task not found');
return task;
}
const project = await this.brain.projects.findById(projectId);
if (!project) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
@Post(':missionId/tasks')
async createTask(
@Param('missionId') missionId: string,
@Body() dto: CreateMissionTaskDto,
@CurrentUser() user: { id: string },
) {
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
return this.brain.missionTasks.create({
missionId,
taskId: dto.taskId,
userId: user.id,
status: dto.status,
description: dto.description,
notes: dto.notes,
pr: dto.pr,
});
}
assertOwner(project.ownerId, userId, resourceName);
return project;
@Patch(':missionId/tasks/:taskId')
async updateTask(
@Param('missionId') missionId: string,
@Param('taskId') taskId: string,
@Body() dto: UpdateMissionTaskDto,
@CurrentUser() user: { id: string },
) {
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
const existing = await this.brain.missionTasks.findByIdAndUser(taskId, user.id);
if (!existing) throw new NotFoundException('Mission task not found');
const updated = await this.brain.missionTasks.update(taskId, dto);
if (!updated) throw new NotFoundException('Mission task not found');
return updated;
}
@Delete(':missionId/tasks/:taskId')
@HttpCode(HttpStatus.NO_CONTENT)
async removeTask(
@Param('missionId') missionId: string,
@Param('taskId') taskId: string,
@CurrentUser() user: { id: string },
) {
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
const existing = await this.brain.missionTasks.findByIdAndUser(taskId, user.id);
if (!existing) throw new NotFoundException('Mission task not found');
const deleted = await this.brain.missionTasks.remove(taskId);
if (!deleted) throw new NotFoundException('Mission task not found');
}
}

View File

@@ -1,6 +1,7 @@
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { IsArray, IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
const missionStatuses = ['planning', 'active', 'paused', 'completed', 'failed'] as const;
const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
export class CreateMissionDto {
@IsString()
@@ -19,6 +20,19 @@ export class CreateMissionDto {
@IsOptional()
@IsIn(missionStatuses)
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
@IsOptional()
@IsString()
@MaxLength(255)
phase?: string;
@IsOptional()
@IsArray()
milestones?: Record<string, unknown>[];
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}
export class UpdateMissionDto {
@@ -40,7 +54,70 @@ export class UpdateMissionDto {
@IsIn(missionStatuses)
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
@IsOptional()
@IsString()
@MaxLength(255)
phase?: string;
@IsOptional()
@IsArray()
milestones?: Record<string, unknown>[];
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown> | null;
}
export class CreateMissionTaskDto {
@IsOptional()
@IsUUID()
taskId?: string;
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
notes?: string;
@IsOptional()
@IsString()
@MaxLength(255)
pr?: string;
}
export class UpdateMissionTaskDto {
@IsOptional()
@IsUUID()
taskId?: string;
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
notes?: string;
@IsOptional()
@IsString()
@MaxLength(255)
pr?: string;
}

View File

@@ -3,7 +3,7 @@
> Single-writer: orchestrator only. Workers read but never modify.
| id | status | milestone | description | pr | notes |
| ------ | ----------- | --------- | ------------------------------------------------------------------- | ---- | ------------- |
| ------ | ----------- | --------- | --------------------------------------------------------------------- | ---- | ------------- |
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
@@ -74,6 +74,8 @@
| P7-006 | done | Phase 7 | Documentation — user guide, admin guide, dev guide | #151 | #57 W9 done |
| P7-007 | done | Phase 7 | Bare-metal deployment docs + .env.example | #153 | #58 W9 done |
| P7-021 | done | Phase 7 | Verify Phase 7 — feature-complete platform E2E | — | #132 W10 done |
| P8-005 | in-progress | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | — | |
| P8-006 | not-started | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | — | |
| P8-001 | not-started | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 |

View File

@@ -0,0 +1,58 @@
import { eq, or, type Db, agents } from '@mosaic/db';
export type Agent = typeof agents.$inferSelect;
export type NewAgent = typeof agents.$inferInsert;
export function createAgentsRepo(db: Db) {
return {
async findAll(): Promise<Agent[]> {
return db.select().from(agents);
},
async findById(id: string): Promise<Agent | undefined> {
const rows = await db.select().from(agents).where(eq(agents.id, id));
return rows[0];
},
async findByName(name: string): Promise<Agent | undefined> {
const rows = await db.select().from(agents).where(eq(agents.name, name));
return rows[0];
},
async findByProject(projectId: string): Promise<Agent[]> {
return db.select().from(agents).where(eq(agents.projectId, projectId));
},
async findSystem(): Promise<Agent[]> {
return db.select().from(agents).where(eq(agents.isSystem, true));
},
async findAccessible(ownerId: string): Promise<Agent[]> {
return db
.select()
.from(agents)
.where(or(eq(agents.ownerId, ownerId), eq(agents.isSystem, true)));
},
async create(data: NewAgent): Promise<Agent> {
const rows = await db.insert(agents).values(data).returning();
return rows[0]!;
},
async update(id: string, data: Partial<NewAgent>): Promise<Agent | undefined> {
const rows = await db
.update(agents)
.set({ ...data, updatedAt: new Date() })
.where(eq(agents.id, id))
.returning();
return rows[0];
},
async remove(id: string): Promise<boolean> {
const rows = await db.delete(agents).where(eq(agents.id, id)).returning();
return rows.length > 0;
},
};
}
export type AgentsRepo = ReturnType<typeof createAgentsRepo>;

View File

@@ -4,6 +4,7 @@ import { createMissionsRepo, type MissionsRepo } from './missions.js';
import { createMissionTasksRepo, type MissionTasksRepo } from './mission-tasks.js';
import { createTasksRepo, type TasksRepo } from './tasks.js';
import { createConversationsRepo, type ConversationsRepo } from './conversations.js';
import { createAgentsRepo, type AgentsRepo } from './agents.js';
export interface Brain {
projects: ProjectsRepo;
@@ -11,6 +12,7 @@ export interface Brain {
missionTasks: MissionTasksRepo;
tasks: TasksRepo;
conversations: ConversationsRepo;
agents: AgentsRepo;
}
export function createBrain(db: Db): Brain {
@@ -20,5 +22,6 @@ export function createBrain(db: Db): Brain {
missionTasks: createMissionTasksRepo(db),
tasks: createTasksRepo(db),
conversations: createConversationsRepo(db),
agents: createAgentsRepo(db),
};
}

View File

@@ -26,3 +26,9 @@ export {
type Message,
type NewMessage,
} from './conversations.js';
export {
createAgentsRepo,
type AgentsRepo,
type Agent as AgentConfig,
type NewAgent as NewAgentConfig,
} from './agents.js';

View File

@@ -21,6 +21,7 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@clack/prompts": "^0.9.0",
"@mosaic/mosaic": "workspace:^",
"@mosaic/prdy": "workspace:^",
"@mosaic/quality-rails": "workspace:^",

View File

@@ -1,8 +1,10 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { buildPrdyCli } from '@mosaic/prdy';
import { createQualityRailsCli } from '@mosaic/quality-rails';
import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js';
import { registerPrdyCommand } from './commands/prdy.js';
const program = new Command();
@@ -51,8 +53,17 @@ program
.option('-c, --conversation <id>', 'Resume a conversation by ID')
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
.option('--agent <idOrName>', 'Connect to a specific agent')
.option('--project <idOrName>', 'Scope session to project')
.action(
async (opts: { gateway: string; conversation?: string; model?: string; provider?: string }) => {
async (opts: {
gateway: string;
conversation?: string;
model?: string;
provider?: string;
agent?: string;
project?: string;
}) => {
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
// Try loading saved session
@@ -89,6 +100,50 @@ program
}
}
// Resolve agent ID if --agent was passed by name
let agentId: string | undefined;
let agentName: string | undefined;
if (opts.agent) {
try {
const { fetchAgentConfigs } = await import('./tui/gateway-api.js');
const agents = await fetchAgentConfigs(opts.gateway, session.cookie);
const match = agents.find((a) => a.id === opts.agent || a.name === opts.agent);
if (match) {
agentId = match.id;
agentName = match.name;
} else {
console.error(`Agent "${opts.agent}" not found.`);
process.exit(1);
}
} catch (err) {
console.error(
`Failed to resolve agent: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
}
// Resolve project ID if --project was passed by name
let projectId: string | undefined;
if (opts.project) {
try {
const { fetchProjects } = await import('./tui/gateway-api.js');
const projects = await fetchProjects(opts.gateway, session.cookie);
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
if (match) {
projectId = match.id;
} else {
console.error(`Project "${opts.project}" not found.`);
process.exit(1);
}
} catch (err) {
console.error(
`Failed to resolve project: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
}
// Dynamic import to avoid loading React/Ink for other commands
const { render } = await import('ink');
const React = await import('react');
@@ -101,6 +156,9 @@ program
sessionCookie: session.cookie,
initialModel: opts.model,
initialProvider: opts.provider,
agentId,
agentName: agentName ?? undefined,
projectId,
}),
);
},
@@ -115,23 +173,12 @@ sessionsCmd
.description('List active agent sessions')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.action(async (opts: { gateway: string }) => {
const { loadSession, validateSession } = await import('./auth.js');
const { withAuth } = await import('./commands/with-auth.js');
const auth = await withAuth(opts.gateway);
const { fetchSessions } = await import('./tui/gateway-api.js');
const session = loadSession(opts.gateway);
if (!session) {
console.error('Not signed in. Run `mosaic login` first.');
process.exit(1);
}
const valid = await validateSession(opts.gateway, session.cookie);
if (!valid) {
console.error('Session expired. Run `mosaic login` again.');
process.exit(1);
}
try {
const result = await fetchSessions(opts.gateway, session.cookie);
const result = await fetchSessions(auth.gateway, auth.cookie);
if (result.total === 0) {
console.log('No active sessions.');
return;
@@ -193,23 +240,12 @@ sessionsCmd
.description('Terminate an active agent session')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.action(async (id: string, opts: { gateway: string }) => {
const { loadSession, validateSession } = await import('./auth.js');
const { withAuth } = await import('./commands/with-auth.js');
const auth = await withAuth(opts.gateway);
const { deleteSession } = await import('./tui/gateway-api.js');
const session = loadSession(opts.gateway);
if (!session) {
console.error('Not signed in. Run `mosaic login` first.');
process.exit(1);
}
const valid = await validateSession(opts.gateway, session.cookie);
if (!valid) {
console.error('Session expired. Run `mosaic login` again.');
process.exit(1);
}
try {
await deleteSession(opts.gateway, session.cookie, id);
await deleteSession(auth.gateway, auth.cookie, id);
console.log(`Session ${id} destroyed.`);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
@@ -217,13 +253,17 @@ sessionsCmd
}
});
// ─── prdy ───────────────────────────────────────────────────────────────
// ─── agent ─────────────────────────────────────────────────────────────
const prdyWrapper = buildPrdyCli();
const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy');
if (prdyCmd !== undefined) {
program.addCommand(prdyCmd as unknown as Command);
}
registerAgentCommand(program);
// ─── mission ───────────────────────────────────────────────────────────
registerMissionCommand(program);
// ─── prdy ──────────────────────────────────────────────────────────────
registerPrdyCommand(program);
// ─── quality-rails ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,241 @@
import type { Command } from 'commander';
import { withAuth } from './with-auth.js';
import { selectItem } from './select-dialog.js';
import {
fetchAgentConfigs,
createAgentConfig,
updateAgentConfig,
deleteAgentConfig,
fetchProjects,
fetchProviders,
} from '../tui/gateway-api.js';
import type { AgentConfigInfo } from '../tui/gateway-api.js';
function formatAgent(a: AgentConfigInfo): string {
const sys = a.isSystem ? ' [system]' : '';
return `${a.name}${sys}${a.provider}/${a.model} (${a.status})`;
}
function showAgentDetail(a: AgentConfigInfo) {
console.log(` ID: ${a.id}`);
console.log(` Name: ${a.name}`);
console.log(` Provider: ${a.provider}`);
console.log(` Model: ${a.model}`);
console.log(` Status: ${a.status}`);
console.log(` System: ${a.isSystem ? 'yes' : 'no'}`);
console.log(` Project: ${a.projectId ?? '—'}`);
console.log(` System Prompt: ${a.systemPrompt ? `${a.systemPrompt.slice(0, 80)}...` : '—'}`);
console.log(` Tools: ${a.allowedTools ? a.allowedTools.join(', ') : 'all'}`);
console.log(` Skills: ${a.skills ? a.skills.join(', ') : '—'}`);
console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`);
}
export function registerAgentCommand(program: Command) {
const cmd = program
.command('agent')
.description('Manage agent configurations')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('--list', 'List all agents')
.option('--new', 'Create a new agent')
.option('--show <idOrName>', 'Show agent details')
.option('--update <idOrName>', 'Update an agent')
.option('--delete <idOrName>', 'Delete an agent')
.action(
async (opts: {
gateway: string;
list?: boolean;
new?: boolean;
show?: string;
update?: string;
delete?: string;
}) => {
const auth = await withAuth(opts.gateway);
if (opts.list) {
return listAgents(auth.gateway, auth.cookie);
}
if (opts.new) {
return createAgentWizard(auth.gateway, auth.cookie);
}
if (opts.show) {
return showAgent(auth.gateway, auth.cookie, opts.show);
}
if (opts.update) {
return updateAgentWizard(auth.gateway, auth.cookie, opts.update);
}
if (opts.delete) {
return deleteAgent(auth.gateway, auth.cookie, opts.delete);
}
// Default: interactive select
return interactiveSelect(auth.gateway, auth.cookie);
},
);
return cmd;
}
async function resolveAgent(
gateway: string,
cookie: string,
idOrName: string,
): Promise<AgentConfigInfo | undefined> {
const agents = await fetchAgentConfigs(gateway, cookie);
return agents.find((a) => a.id === idOrName || a.name === idOrName);
}
async function listAgents(gateway: string, cookie: string) {
const agents = await fetchAgentConfigs(gateway, cookie);
if (agents.length === 0) {
console.log('No agents found.');
return;
}
console.log(`Agents (${agents.length}):\n`);
for (const a of agents) {
const sys = a.isSystem ? ' [system]' : '';
const project = a.projectId ? ` project=${a.projectId.slice(0, 8)}` : '';
console.log(` ${a.name}${sys} ${a.provider}/${a.model} ${a.status}${project}`);
}
}
async function showAgent(gateway: string, cookie: string, idOrName: string) {
const agent = await resolveAgent(gateway, cookie, idOrName);
if (!agent) {
console.error(`Agent "${idOrName}" not found.`);
process.exit(1);
}
showAgentDetail(agent);
}
async function interactiveSelect(gateway: string, cookie: string) {
const agents = await fetchAgentConfigs(gateway, cookie);
const selected = await selectItem(agents, {
message: 'Select an agent:',
render: formatAgent,
emptyMessage: 'No agents found. Create one with `mosaic agent --new`.',
});
if (selected) {
showAgentDetail(selected);
}
}
async function createAgentWizard(gateway: string, cookie: string) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
const name = await ask('Agent name: ');
if (!name.trim()) {
console.error('Name is required.');
return;
}
// Project selection
const projects = await fetchProjects(gateway, cookie);
let projectId: string | undefined;
if (projects.length > 0) {
const selected = await selectItem(projects, {
message: 'Assign to project (optional):',
render: (p) => `${p.name} (${p.status})`,
});
if (selected) projectId = selected.id;
}
// Provider / model selection
const providers = await fetchProviders(gateway, cookie);
let provider = 'default';
let model = 'default';
if (providers.length > 0) {
const allModels = providers.flatMap((p) =>
p.models.map((m) => ({ provider: p.name, model: m.id, label: `${p.name}/${m.id}` })),
);
if (allModels.length > 0) {
const selected = await selectItem(allModels, {
message: 'Select model:',
render: (m) => m.label,
});
if (selected) {
provider = selected.provider;
model = selected.model;
}
}
}
const systemPrompt = await ask('System prompt (optional, press Enter to skip): ');
const agent = await createAgentConfig(gateway, cookie, {
name: name.trim(),
provider,
model,
projectId,
systemPrompt: systemPrompt.trim() || undefined,
});
console.log(`\nAgent "${agent.name}" created (${agent.id}).`);
} finally {
rl.close();
}
}
async function updateAgentWizard(gateway: string, cookie: string, idOrName: string) {
const agent = await resolveAgent(gateway, cookie, idOrName);
if (!agent) {
console.error(`Agent "${idOrName}" not found.`);
process.exit(1);
}
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
console.log(`Updating agent: ${agent.name}\n`);
const name = await ask(`Name [${agent.name}]: `);
const systemPrompt = await ask(`System prompt [${agent.systemPrompt ? 'set' : 'none'}]: `);
const updates: Record<string, unknown> = {};
if (name.trim()) updates['name'] = name.trim();
if (systemPrompt.trim()) updates['systemPrompt'] = systemPrompt.trim();
if (Object.keys(updates).length === 0) {
console.log('No changes.');
return;
}
const updated = await updateAgentConfig(gateway, cookie, agent.id, updates);
console.log(`\nAgent "${updated.name}" updated.`);
} finally {
rl.close();
}
}
async function deleteAgent(gateway: string, cookie: string, idOrName: string) {
const agent = await resolveAgent(gateway, cookie, idOrName);
if (!agent) {
console.error(`Agent "${idOrName}" not found.`);
process.exit(1);
}
if (agent.isSystem) {
console.error('Cannot delete system agents.');
process.exit(1);
}
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) =>
rl.question(`Delete agent "${agent.name}"? (y/N): `, resolve),
);
rl.close();
if (answer.toLowerCase() !== 'y') {
console.log('Cancelled.');
return;
}
await deleteAgentConfig(gateway, cookie, agent.id);
console.log(`Agent "${agent.name}" deleted.`);
}

View File

@@ -0,0 +1,385 @@
import type { Command } from 'commander';
import { withAuth } from './with-auth.js';
import { selectItem } from './select-dialog.js';
import {
fetchMissions,
fetchMission,
createMission,
updateMission,
fetchMissionTasks,
createMissionTask,
updateMissionTask,
fetchProjects,
} from '../tui/gateway-api.js';
import type { MissionInfo, MissionTaskInfo } from '../tui/gateway-api.js';
function formatMission(m: MissionInfo): string {
return `${m.name}${m.status}${m.phase ? ` (${m.phase})` : ''}`;
}
function showMissionDetail(m: MissionInfo) {
console.log(` ID: ${m.id}`);
console.log(` Name: ${m.name}`);
console.log(` Status: ${m.status}`);
console.log(` Phase: ${m.phase ?? '—'}`);
console.log(` Project: ${m.projectId ?? '—'}`);
console.log(` Description: ${m.description ?? '—'}`);
console.log(` Created: ${new Date(m.createdAt).toLocaleString()}`);
}
function showTaskDetail(t: MissionTaskInfo) {
console.log(` ID: ${t.id}`);
console.log(` Status: ${t.status}`);
console.log(` Description: ${t.description ?? '—'}`);
console.log(` Notes: ${t.notes ?? '—'}`);
console.log(` PR: ${t.pr ?? '—'}`);
console.log(` Created: ${new Date(t.createdAt).toLocaleString()}`);
}
export function registerMissionCommand(program: Command) {
const cmd = program
.command('mission')
.description('Manage missions')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('--list', 'List all missions')
.option('--init', 'Create a new mission')
.option('--plan <idOrName>', 'Run PRD wizard for a mission')
.option('--update <idOrName>', 'Update a mission')
.option('--project <idOrName>', 'Scope to project')
.argument('[id]', 'Show mission detail by ID')
.action(
async (
id: string | undefined,
opts: {
gateway: string;
list?: boolean;
init?: boolean;
plan?: string;
update?: string;
project?: string;
},
) => {
const auth = await withAuth(opts.gateway);
if (opts.list) {
return listMissions(auth.gateway, auth.cookie);
}
if (opts.init) {
return initMission(auth.gateway, auth.cookie);
}
if (opts.plan) {
return planMission(auth.gateway, auth.cookie, opts.plan, opts.project);
}
if (opts.update) {
return updateMissionWizard(auth.gateway, auth.cookie, opts.update);
}
if (id) {
return showMission(auth.gateway, auth.cookie, id);
}
// Default: interactive select
return interactiveSelect(auth.gateway, auth.cookie);
},
);
// Task subcommand
cmd
.command('task')
.description('Manage mission tasks')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('--list', 'List tasks for a mission')
.option('--new', 'Create a task')
.option('--update <taskId>', 'Update a task')
.option('--mission <idOrName>', 'Mission ID or name')
.argument('[taskId]', 'Show task detail')
.action(
async (
taskId: string | undefined,
taskOpts: {
gateway: string;
list?: boolean;
new?: boolean;
update?: string;
mission?: string;
},
) => {
const auth = await withAuth(taskOpts.gateway);
const missionId = await resolveMissionId(auth.gateway, auth.cookie, taskOpts.mission);
if (!missionId) return;
if (taskOpts.list) {
return listTasks(auth.gateway, auth.cookie, missionId);
}
if (taskOpts.new) {
return createTaskWizard(auth.gateway, auth.cookie, missionId);
}
if (taskOpts.update) {
return updateTaskWizard(auth.gateway, auth.cookie, missionId, taskOpts.update);
}
if (taskId) {
return showTask(auth.gateway, auth.cookie, missionId, taskId);
}
return listTasks(auth.gateway, auth.cookie, missionId);
},
);
return cmd;
}
async function resolveMissionByName(
gateway: string,
cookie: string,
idOrName: string,
): Promise<MissionInfo | undefined> {
const missions = await fetchMissions(gateway, cookie);
return missions.find((m) => m.id === idOrName || m.name === idOrName);
}
async function resolveMissionId(
gateway: string,
cookie: string,
idOrName?: string,
): Promise<string | undefined> {
if (idOrName) {
const mission = await resolveMissionByName(gateway, cookie, idOrName);
if (!mission) {
console.error(`Mission "${idOrName}" not found.`);
return undefined;
}
return mission.id;
}
// Interactive select
const missions = await fetchMissions(gateway, cookie);
const selected = await selectItem(missions, {
message: 'Select a mission:',
render: formatMission,
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
});
return selected?.id;
}
async function listMissions(gateway: string, cookie: string) {
const missions = await fetchMissions(gateway, cookie);
if (missions.length === 0) {
console.log('No missions found.');
return;
}
console.log(`Missions (${missions.length}):\n`);
for (const m of missions) {
const phase = m.phase ? ` [${m.phase}]` : '';
console.log(` ${m.name} ${m.status}${phase} ${m.id.slice(0, 8)}`);
}
}
async function showMission(gateway: string, cookie: string, id: string) {
try {
const mission = await fetchMission(gateway, cookie, id);
showMissionDetail(mission);
} catch {
// Try resolving by name
const m = await resolveMissionByName(gateway, cookie, id);
if (!m) {
console.error(`Mission "${id}" not found.`);
process.exit(1);
}
showMissionDetail(m);
}
}
async function interactiveSelect(gateway: string, cookie: string) {
const missions = await fetchMissions(gateway, cookie);
const selected = await selectItem(missions, {
message: 'Select a mission:',
render: formatMission,
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
});
if (selected) {
showMissionDetail(selected);
}
}
async function initMission(gateway: string, cookie: string) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
const name = await ask('Mission name: ');
if (!name.trim()) {
console.error('Name is required.');
return;
}
// Project selection
const projects = await fetchProjects(gateway, cookie);
let projectId: string | undefined;
if (projects.length > 0) {
const selected = await selectItem(projects, {
message: 'Assign to project (required):',
render: (p) => `${p.name} (${p.status})`,
emptyMessage: 'No projects found.',
});
if (selected) projectId = selected.id;
}
const description = await ask('Description (optional): ');
const mission = await createMission(gateway, cookie, {
name: name.trim(),
projectId,
description: description.trim() || undefined,
status: 'planning',
});
console.log(`\nMission "${mission.name}" created (${mission.id}).`);
} finally {
rl.close();
}
}
async function planMission(
gateway: string,
cookie: string,
idOrName: string,
_projectIdOrName?: string,
) {
const mission = await resolveMissionByName(gateway, cookie, idOrName);
if (!mission) {
console.error(`Mission "${idOrName}" not found.`);
process.exit(1);
}
console.log(`Planning mission: ${mission.name}\n`);
try {
const { runPrdWizard } = await import('@mosaic/prdy');
await runPrdWizard({
name: mission.name,
projectPath: process.cwd(),
interactive: true,
});
} catch (err) {
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function updateMissionWizard(gateway: string, cookie: string, idOrName: string) {
const mission = await resolveMissionByName(gateway, cookie, idOrName);
if (!mission) {
console.error(`Mission "${idOrName}" not found.`);
process.exit(1);
}
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
console.log(`Updating mission: ${mission.name}\n`);
const name = await ask(`Name [${mission.name}]: `);
const description = await ask(`Description [${mission.description ?? 'none'}]: `);
const status = await ask(`Status [${mission.status}]: `);
const updates: Record<string, unknown> = {};
if (name.trim()) updates['name'] = name.trim();
if (description.trim()) updates['description'] = description.trim();
if (status.trim()) updates['status'] = status.trim();
if (Object.keys(updates).length === 0) {
console.log('No changes.');
return;
}
const updated = await updateMission(gateway, cookie, mission.id, updates);
console.log(`\nMission "${updated.name}" updated.`);
} finally {
rl.close();
}
}
// ── Task operations ──
async function listTasks(gateway: string, cookie: string, missionId: string) {
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
if (tasks.length === 0) {
console.log('No tasks found.');
return;
}
console.log(`Tasks (${tasks.length}):\n`);
for (const t of tasks) {
const desc = t.description ? `${t.description.slice(0, 60)}` : '';
console.log(` ${t.id.slice(0, 8)} ${t.status}${desc}`);
}
}
async function showTask(gateway: string, cookie: string, missionId: string, taskId: string) {
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
const task = tasks.find((t) => t.id === taskId);
if (!task) {
console.error(`Task "${taskId}" not found.`);
process.exit(1);
}
showTaskDetail(task);
}
async function createTaskWizard(gateway: string, cookie: string, missionId: string) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
const description = await ask('Task description: ');
if (!description.trim()) {
console.error('Description is required.');
return;
}
const status = await ask('Status [not-started]: ');
const task = await createMissionTask(gateway, cookie, missionId, {
description: description.trim(),
status: status.trim() || 'not-started',
});
console.log(`\nTask created (${task.id}).`);
} finally {
rl.close();
}
}
async function updateTaskWizard(
gateway: string,
cookie: string,
missionId: string,
taskId: string,
) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
const status = await ask('New status: ');
const notes = await ask('Notes (optional): ');
const pr = await ask('PR (optional): ');
const updates: Record<string, unknown> = {};
if (status.trim()) updates['status'] = status.trim();
if (notes.trim()) updates['notes'] = notes.trim();
if (pr.trim()) updates['pr'] = pr.trim();
if (Object.keys(updates).length === 0) {
console.log('No changes.');
return;
}
const updated = await updateMissionTask(gateway, cookie, missionId, taskId, updates);
console.log(`\nTask ${updated.id.slice(0, 8)} updated (${updated.status}).`);
} finally {
rl.close();
}
}

View File

@@ -0,0 +1,55 @@
import type { Command } from 'commander';
import { withAuth } from './with-auth.js';
import { fetchProjects } from '../tui/gateway-api.js';
export function registerPrdyCommand(program: Command) {
const cmd = program
.command('prdy')
.description('PRD wizard — create and manage Product Requirement Documents')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('--init [name]', 'Create a new PRD')
.option('--update [name]', 'Update an existing PRD')
.option('--project <idOrName>', 'Scope to project')
.action(
async (opts: {
gateway: string;
init?: string | boolean;
update?: string | boolean;
project?: string;
}) => {
// Detect project context when --project flag is provided
if (opts.project) {
try {
const auth = await withAuth(opts.gateway);
const projects = await fetchProjects(auth.gateway, auth.cookie);
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
if (match) {
console.log(`Project context: ${match.name} (${match.id})\n`);
}
} catch {
// Gateway not available — proceed without project context
}
}
try {
const { runPrdWizard } = await import('@mosaic/prdy');
const name =
typeof opts.init === 'string'
? opts.init
: typeof opts.update === 'string'
? opts.update
: 'untitled';
await runPrdWizard({
name,
projectPath: process.cwd(),
interactive: true,
});
} catch (err) {
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
},
);
return cmd;
}

View File

@@ -0,0 +1,58 @@
/**
* Interactive item selection. Uses @clack/prompts when TTY, falls back to numbered list.
*/
export async function selectItem<T>(
items: T[],
opts: {
message: string;
render: (item: T) => string;
emptyMessage?: string;
},
): Promise<T | undefined> {
if (items.length === 0) {
console.log(opts.emptyMessage ?? 'No items found.');
return undefined;
}
const isTTY = process.stdin.isTTY;
if (isTTY) {
try {
const { select } = await import('@clack/prompts');
const result = await select({
message: opts.message,
options: items.map((item, i) => ({
value: i,
label: opts.render(item),
})),
});
if (typeof result === 'symbol') {
return undefined;
}
return items[result as number];
} catch {
// Fall through to non-interactive
}
}
// Non-interactive: display numbered list and read a number
console.log(`\n${opts.message}\n`);
for (let i = 0; i < items.length; i++) {
console.log(` ${i + 1}. ${opts.render(items[i]!)}`);
}
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) => rl.question('\nSelect: ', resolve));
rl.close();
const index = parseInt(answer, 10) - 1;
if (isNaN(index) || index < 0 || index >= items.length) {
console.error('Invalid selection.');
return undefined;
}
return items[index];
}

View File

@@ -0,0 +1,29 @@
import type { AuthResult } from '../auth.js';
export interface AuthContext {
gateway: string;
session: AuthResult;
cookie: string;
}
/**
* Load and validate the user's auth session.
* Exits with an error message if not signed in or session expired.
*/
export async function withAuth(gateway: string): Promise<AuthContext> {
const { loadSession, validateSession } = await import('../auth.js');
const session = loadSession(gateway);
if (!session) {
console.error('Not signed in. Run `mosaic login` first.');
process.exit(1);
}
const valid = await validateSession(gateway, session.cookie);
if (!valid) {
console.error('Session expired. Run `mosaic login` again.');
process.exit(1);
}
return { gateway, session, cookie: session.cookie };
}

View File

@@ -19,6 +19,9 @@ export interface TuiAppProps {
sessionCookie?: string;
initialModel?: string;
initialProvider?: string;
agentId?: string;
agentName?: string;
projectId?: string;
}
export function TuiApp({
@@ -27,6 +30,9 @@ export function TuiApp({
sessionCookie,
initialModel,
initialProvider,
agentId,
agentName,
projectId: _projectId,
}: TuiAppProps) {
const { exit } = useApp();
const gitInfo = useGitInfo();
@@ -38,6 +44,7 @@ export function TuiApp({
initialConversationId: conversationId,
initialModel,
initialProvider,
agentId,
});
const conversations = useConversations({ gatewayUrl, sessionCookie });
@@ -211,7 +218,7 @@ export function TuiApp({
modelName={socket.modelName}
thinkingLevel={socket.thinkingLevel}
contextWindow={socket.tokenUsage.contextWindow}
agentName="default"
agentName={agentName ?? 'default'}
connected={socket.connected}
connecting={socket.connecting}
/>

View File

@@ -1,5 +1,5 @@
/**
* Minimal gateway REST API client for the TUI.
* Minimal gateway REST API client for the TUI and CLI commands.
*/
export interface ModelInfo {
@@ -30,10 +30,88 @@ export interface SessionListResult {
total: number;
}
/**
* Fetch the list of available models from the gateway.
* Returns an empty array on network or auth errors so the TUI can still function.
*/
// ── Agent Config types ──
export interface AgentConfigInfo {
id: string;
name: string;
provider: string;
model: string;
status: string;
projectId: string | null;
ownerId: string | null;
systemPrompt: string | null;
allowedTools: string[] | null;
skills: string[] | null;
isSystem: boolean;
config: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
// ── Project types ──
export interface ProjectInfo {
id: string;
name: string;
description: string | null;
status: string;
ownerId: string | null;
createdAt: string;
updatedAt: string;
}
// ── Mission types ──
export interface MissionInfo {
id: string;
name: string;
description: string | null;
status: string;
projectId: string | null;
userId: string | null;
phase: string | null;
milestones: Record<string, unknown>[] | null;
config: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
// ── Mission Task types ──
export interface MissionTaskInfo {
id: string;
missionId: string;
taskId: string | null;
userId: string;
status: string;
description: string | null;
notes: string | null;
pr: string | null;
createdAt: string;
updatedAt: string;
}
// ── Helpers ──
function headers(sessionCookie: string, gatewayUrl: string) {
return { Cookie: sessionCookie, Origin: gatewayUrl };
}
function jsonHeaders(sessionCookie: string, gatewayUrl: string) {
return { ...headers(sessionCookie, gatewayUrl), 'Content-Type': 'application/json' };
}
async function handleResponse<T>(res: Response, errorPrefix: string): Promise<T> {
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`${errorPrefix} (${res.status}): ${body}`);
}
return (await res.json()) as T;
}
// ── Provider / Model endpoints ──
export async function fetchAvailableModels(
gatewayUrl: string,
sessionCookie?: string,
@@ -53,10 +131,6 @@ export async function fetchAvailableModels(
}
}
/**
* Fetch the list of providers (with their models) from the gateway.
* Returns an empty array on network or auth errors.
*/
export async function fetchProviders(
gatewayUrl: string,
sessionCookie?: string,
@@ -76,28 +150,18 @@ export async function fetchProviders(
}
}
/**
* Fetch the list of active agent sessions from the gateway.
* Throws on network or auth errors.
*/
// ── Session endpoints ──
export async function fetchSessions(
gatewayUrl: string,
sessionCookie: string,
): Promise<SessionListResult> {
const res = await fetch(`${gatewayUrl}/api/sessions`, {
headers: { Cookie: sessionCookie, Origin: gatewayUrl },
headers: headers(sessionCookie, gatewayUrl),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to list sessions (${res.status}): ${body}`);
}
return (await res.json()) as SessionListResult;
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
}
/**
* Destroy (terminate) an agent session on the gateway.
* Throws on network or auth errors.
*/
export async function deleteSession(
gatewayUrl: string,
sessionCookie: string,
@@ -105,10 +169,220 @@ export async function deleteSession(
): Promise<void> {
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
headers: { Cookie: sessionCookie, Origin: gatewayUrl },
headers: headers(sessionCookie, gatewayUrl),
});
if (!res.ok && res.status !== 204) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
}
}
// ── Agent Config endpoints ──
export async function fetchAgentConfigs(
gatewayUrl: string,
sessionCookie: string,
): Promise<AgentConfigInfo[]> {
const res = await fetch(`${gatewayUrl}/api/agents`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<AgentConfigInfo[]>(res, 'Failed to list agents');
}
export async function fetchAgentConfig(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<AgentConfigInfo> {
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<AgentConfigInfo>(res, 'Failed to get agent');
}
export async function createAgentConfig(
gatewayUrl: string,
sessionCookie: string,
data: {
name: string;
provider: string;
model: string;
projectId?: string;
systemPrompt?: string;
allowedTools?: string[];
skills?: string[];
config?: Record<string, unknown>;
},
): Promise<AgentConfigInfo> {
const res = await fetch(`${gatewayUrl}/api/agents`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<AgentConfigInfo>(res, 'Failed to create agent');
}
export async function updateAgentConfig(
gatewayUrl: string,
sessionCookie: string,
id: string,
data: Record<string, unknown>,
): Promise<AgentConfigInfo> {
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<AgentConfigInfo>(res, 'Failed to update agent');
}
export async function deleteAgentConfig(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<void> {
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: headers(sessionCookie, gatewayUrl),
});
if (!res.ok && res.status !== 204) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to delete agent (${res.status}): ${body}`);
}
}
// ── Project endpoints ──
export async function fetchProjects(
gatewayUrl: string,
sessionCookie: string,
): Promise<ProjectInfo[]> {
const res = await fetch(`${gatewayUrl}/api/projects`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<ProjectInfo[]>(res, 'Failed to list projects');
}
// ── Mission endpoints ──
export async function fetchMissions(
gatewayUrl: string,
sessionCookie: string,
): Promise<MissionInfo[]> {
const res = await fetch(`${gatewayUrl}/api/missions`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<MissionInfo[]>(res, 'Failed to list missions');
}
export async function fetchMission(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<MissionInfo> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<MissionInfo>(res, 'Failed to get mission');
}
export async function createMission(
gatewayUrl: string,
sessionCookie: string,
data: {
name: string;
description?: string;
projectId?: string;
status?: string;
phase?: string;
milestones?: Record<string, unknown>[];
config?: Record<string, unknown>;
},
): Promise<MissionInfo> {
const res = await fetch(`${gatewayUrl}/api/missions`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<MissionInfo>(res, 'Failed to create mission');
}
export async function updateMission(
gatewayUrl: string,
sessionCookie: string,
id: string,
data: Record<string, unknown>,
): Promise<MissionInfo> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<MissionInfo>(res, 'Failed to update mission');
}
export async function deleteMission(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<void> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: headers(sessionCookie, gatewayUrl),
});
if (!res.ok && res.status !== 204) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to delete mission (${res.status}): ${body}`);
}
}
// ── Mission Task endpoints ──
export async function fetchMissionTasks(
gatewayUrl: string,
sessionCookie: string,
missionId: string,
): Promise<MissionTaskInfo[]> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<MissionTaskInfo[]>(res, 'Failed to list mission tasks');
}
export async function createMissionTask(
gatewayUrl: string,
sessionCookie: string,
missionId: string,
data: {
description?: string;
status?: string;
notes?: string;
pr?: string;
taskId?: string;
},
): Promise<MissionTaskInfo> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<MissionTaskInfo>(res, 'Failed to create mission task');
}
export async function updateMissionTask(
gatewayUrl: string,
sessionCookie: string,
missionId: string,
taskId: string,
data: Record<string, unknown>,
): Promise<MissionTaskInfo> {
const res = await fetch(
`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks/${encodeURIComponent(taskId)}`,
{
method: 'PATCH',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
},
);
return handleResponse<MissionTaskInfo>(res, 'Failed to update mission task');
}

View File

@@ -43,6 +43,7 @@ export interface UseSocketOptions {
initialConversationId?: string;
initialModel?: string;
initialProvider?: string;
agentId?: string;
}
export interface UseSocketReturn {
@@ -80,7 +81,14 @@ const EMPTY_USAGE: TokenUsage = {
};
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
const { gatewayUrl, sessionCookie, initialConversationId, initialModel, initialProvider } = opts;
const {
gatewayUrl,
sessionCookie,
initialConversationId,
initialModel,
initialProvider,
agentId,
} = opts;
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(true);
@@ -231,6 +239,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
content,
...(initialProvider ? { provider: initialProvider } : {}),
...(initialModel ? { modelId: initialModel } : {}),
...(agentId ? { agentId } : {}),
});
},
[conversationId, isStreaming],

View File

@@ -190,7 +190,9 @@ export const events = pgTable(
(t) => [index('events_type_idx').on(t.type), index('events_date_idx').on(t.date)],
);
export const agents = pgTable('agents', {
export const agents = pgTable(
'agents',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
provider: text('provider').notNull(),
@@ -198,10 +200,22 @@ export const agents = pgTable('agents', {
status: text('status', { enum: ['idle', 'active', 'error', 'offline'] })
.notNull()
.default('idle'),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }),
systemPrompt: text('system_prompt'),
allowedTools: jsonb('allowed_tools').$type<string[]>(),
skills: jsonb('skills').$type<string[]>(),
isSystem: boolean('is_system').notNull().default(false),
config: jsonb('config'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
},
(t) => [
index('agents_project_id_idx').on(t.projectId),
index('agents_owner_id_idx').on(t.ownerId),
index('agents_is_system_idx').on(t.isSystem),
],
);
export const tickets = pgTable(
'tickets',
@@ -243,6 +257,7 @@ export const conversations = pgTable(
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
agentId: uuid('agent_id').references(() => agents.id, { onDelete: 'set null' }),
archived: boolean('archived').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
@@ -250,6 +265,7 @@ export const conversations = pgTable(
(t) => [
index('conversations_user_id_idx').on(t.userId),
index('conversations_project_id_idx').on(t.projectId),
index('conversations_agent_id_idx').on(t.agentId),
index('conversations_archived_idx').on(t.archived),
],
);

View File

@@ -64,6 +64,7 @@ export interface ChatMessagePayload {
content: string;
provider?: string;
modelId?: string;
agentId?: string;
}
/** Session info pushed when session is created or model changes */

3
pnpm-lock.yaml generated
View File

@@ -286,6 +286,9 @@ importers:
packages/cli:
dependencies:
'@clack/prompts':
specifier: ^0.9.0
version: 0.9.1
'@mosaic/mosaic':
specifier: workspace:^
version: link:../mosaic