Compare commits
14 Commits
v0.0.8
...
b649b5c987
| Author | SHA1 | Date | |
|---|---|---|---|
| b649b5c987 | |||
| b4d03a8b49 | |||
| 85aeebbde2 | |||
| a4bb563779 | |||
| 7f6464bbda | |||
| f0741e045f | |||
| 5a1991924c | |||
| bd5d14d07f | |||
| d5a1791dc5 | |||
| bd81c12071 | |||
| 4da255bf04 | |||
| 82c10a7b33 | |||
| d31070177c | |||
| 3792576566 |
@@ -5,9 +5,10 @@ variables:
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
# Turbo remote cache is at turbo.mosaicstack.dev (ducktors/turborepo-remote-cache).
|
||||
# TURBO_TOKEN is a Woodpecker secret injected via from_secret into the environment.
|
||||
# Turbo picks up TURBO_API, TURBO_TOKEN, and TURBO_TEAM automatically.
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
# This avoids from_secret which is blocked on pull_request events.
|
||||
# If the env vars aren't set, turbo falls back to local cache only.
|
||||
|
||||
steps:
|
||||
install:
|
||||
@@ -18,11 +19,6 @@ steps:
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm typecheck
|
||||
@@ -32,11 +28,6 @@ steps:
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm lint
|
||||
@@ -53,11 +44,6 @@ steps:
|
||||
|
||||
test:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm test
|
||||
@@ -66,11 +52,6 @@ steps:
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
97
apps/gateway/src/agent/agent-config.dto.ts
Normal file
97
apps/gateway/src/agent/agent-config.dto.ts
Normal 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;
|
||||
}
|
||||
84
apps/gateway/src/agent/agent-configs.controller.ts
Normal file
84
apps/gateway/src/agent/agent-configs.controller.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,17 @@ 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';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [CoordModule, McpClientModule, SkillsModule],
|
||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
|
||||
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
||||
controllers: [ProvidersController, SessionsController],
|
||||
controllers: [ProvidersController, SessionsController, AgentConfigsController],
|
||||
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
||||
})
|
||||
export class AgentModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
@@ -24,6 +24,9 @@ import { createGitTools } from './tools/git-tools.js';
|
||||
import { createShellTools } from './tools/shell-tools.js';
|
||||
import { createWebTools } from './tools/web-tools.js';
|
||||
import type { SessionInfoDto } from './session.dto.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
|
||||
export interface AgentSessionOptions {
|
||||
provider?: string;
|
||||
@@ -49,6 +52,14 @@ 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;
|
||||
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
@@ -67,6 +78,8 @@ export interface AgentSession {
|
||||
sandboxDir: string;
|
||||
/** Tool names available in this session, or null when all tools are available. */
|
||||
allowedTools: string[] | null;
|
||||
/** User ID that owns this session, used for preference lookups. */
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -83,6 +96,13 @@ export class AgentService implements OnModuleDestroy {
|
||||
@Inject(CoordService) private readonly coordService: CoordService,
|
||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||
@Optional()
|
||||
@Inject(SystemOverrideService)
|
||||
private readonly systemOverride: SystemOverrideService | null,
|
||||
@Optional()
|
||||
@Inject(PreferencesService)
|
||||
private readonly preferencesService: PreferencesService | null,
|
||||
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -146,16 +166,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 +237,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;
|
||||
|
||||
@@ -255,6 +299,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
skillPromptAdditions: promptAdditions,
|
||||
sandboxDir,
|
||||
allowedTools,
|
||||
userId: mergedOptions?.userId,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
@@ -338,8 +383,20 @@ export class AgentService implements OnModuleDestroy {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
session.promptCount += 1;
|
||||
|
||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||
let effectiveMessage = message;
|
||||
if (this.systemOverride) {
|
||||
const override = await this.systemOverride.get(sessionId);
|
||||
if (override) {
|
||||
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||
await this.systemOverride.renew(sessionId);
|
||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await session.piSession.prompt(message);
|
||||
await session.piSession.prompt(effectiveMessage);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
||||
@@ -375,6 +432,14 @@ export class AgentService implements OnModuleDestroy {
|
||||
session.listeners.clear();
|
||||
session.channels.clear();
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
// Run GC cleanup for this session (fire and forget, errors are logged)
|
||||
this.gc.collect(sessionId).catch((err: unknown) => {
|
||||
this.logger.error(
|
||||
`GC collect failed for session ${sessionId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||
import { resolve, relative, join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Safety constraint: all file operations are restricted to a base directory.
|
||||
* Paths that escape the sandbox via ../ traversal are rejected.
|
||||
*/
|
||||
function resolveSafe(baseDir: string, inputPath: string): string {
|
||||
const resolved = resolve(baseDir, inputPath);
|
||||
const rel = relative(baseDir, resolved);
|
||||
if (rel.startsWith('..') || resolve(resolved) !== resolve(join(baseDir, rel))) {
|
||||
throw new Error(`Path escape detected: "${inputPath}" resolves outside base directory`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
||||
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
|
||||
@@ -37,8 +24,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
const { path, encoding } = params as { path: string; encoding?: string };
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = resolveSafe(baseDir, path);
|
||||
safePath = guardPath(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
@@ -99,8 +92,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
};
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = resolveSafe(baseDir, path);
|
||||
safePath = guardPathUnsafe(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
@@ -151,8 +150,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
const target = path ?? '.';
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = resolveSafe(baseDir, target);
|
||||
safePath = guardPath(target, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
|
||||
@@ -2,29 +2,13 @@ import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { resolve, relative } from 'node:path';
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const GIT_TIMEOUT_MS = 15_000;
|
||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
||||
|
||||
/**
|
||||
* Clamp a user-supplied cwd to within the sandbox directory.
|
||||
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
|
||||
* falls back to the sandbox directory itself.
|
||||
*/
|
||||
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
|
||||
if (!requestedCwd) return sandboxDir;
|
||||
const resolved = resolve(sandboxDir, requestedCwd);
|
||||
const rel = relative(sandboxDir, resolved);
|
||||
if (rel.startsWith('..') || rel.startsWith('/')) {
|
||||
// Escape attempt — fall back to sandbox root
|
||||
return sandboxDir;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function runGit(
|
||||
args: string[],
|
||||
cwd?: string,
|
||||
@@ -74,7 +58,21 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { cwd } = params as { cwd?: string };
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
let safeCwd: string;
|
||||
try {
|
||||
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
const result = await runGit(['status', '--short', '--branch'], safeCwd);
|
||||
const text = result.error
|
||||
? `Error: ${result.error}\n${result.stderr}`
|
||||
@@ -107,7 +105,21 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
||||
oneline?: boolean;
|
||||
cwd?: string;
|
||||
};
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
let safeCwd: string;
|
||||
try {
|
||||
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
const args = ['log', `--max-count=${limit ?? 20}`];
|
||||
if (oneline !== false) args.push('--oneline');
|
||||
const result = await runGit(args, safeCwd);
|
||||
@@ -148,12 +160,43 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
||||
path?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
let safeCwd: string;
|
||||
try {
|
||||
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
let safePath: string | undefined;
|
||||
if (path !== undefined) {
|
||||
try {
|
||||
safePath = guardPathUnsafe(path, defaultCwd);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
const args = ['diff'];
|
||||
if (staged) args.push('--cached');
|
||||
if (ref) args.push(ref);
|
||||
args.push('--');
|
||||
if (path) args.push(path);
|
||||
if (safePath !== undefined) args.push(safePath);
|
||||
const result = await runGit(args, safeCwd);
|
||||
const text = result.error
|
||||
? `Error: ${result.error}\n${result.stderr}`
|
||||
|
||||
104
apps/gateway/src/agent/tools/path-guard.test.ts
Normal file
104
apps/gateway/src/agent/tools/path-guard.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
|
||||
describe('guardPathUnsafe', () => {
|
||||
const sandbox = '/tmp/test-sandbox';
|
||||
|
||||
it('allows paths inside sandbox', () => {
|
||||
const result = guardPathUnsafe('foo/bar.txt', sandbox);
|
||||
expect(result).toBe(path.resolve(sandbox, 'foo/bar.txt'));
|
||||
});
|
||||
|
||||
it('allows sandbox root itself', () => {
|
||||
const result = guardPathUnsafe('.', sandbox);
|
||||
expect(result).toBe(path.resolve(sandbox));
|
||||
});
|
||||
|
||||
it('rejects path traversal with ../', () => {
|
||||
expect(() => guardPathUnsafe('../escape.txt', sandbox)).toThrow(SandboxEscapeError);
|
||||
});
|
||||
|
||||
it('rejects absolute path outside sandbox', () => {
|
||||
expect(() => guardPathUnsafe('/etc/passwd', sandbox)).toThrow(SandboxEscapeError);
|
||||
});
|
||||
|
||||
it('rejects deeply nested traversal', () => {
|
||||
expect(() => guardPathUnsafe('a/b/../../../../../../etc/passwd', sandbox)).toThrow(
|
||||
SandboxEscapeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects path that starts with sandbox name but is sibling', () => {
|
||||
expect(() => guardPathUnsafe('/tmp/test-sandbox-evil/file.txt', sandbox)).toThrow(
|
||||
SandboxEscapeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the resolved absolute path for nested paths', () => {
|
||||
const result = guardPathUnsafe('deep/nested/file.ts', sandbox);
|
||||
expect(result).toBe('/tmp/test-sandbox/deep/nested/file.ts');
|
||||
});
|
||||
|
||||
it('SandboxEscapeError includes the user path and sandbox in message', () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
guardPathUnsafe('../escape.txt', sandbox);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SandboxEscapeError);
|
||||
const e = caught as SandboxEscapeError;
|
||||
expect(e.userPath).toBe('../escape.txt');
|
||||
expect(e.sandboxDir).toBe(sandbox);
|
||||
expect(e.message).toContain('Path escape attempt blocked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardPath', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
it('allows an existing path inside a real temp sandbox', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
const subdir = path.join(tmpDir, 'subdir');
|
||||
fs.mkdirSync(subdir);
|
||||
const result = guardPath('subdir', tmpDir);
|
||||
expect(result).toBe(subdir);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('allows sandbox root itself', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
const result = guardPath('.', tmpDir);
|
||||
// realpathSync resolves the tmpdir symlinks (macOS /var -> /private/var)
|
||||
const realTmp = fs.realpathSync.native(tmpDir);
|
||||
expect(result).toBe(realTmp);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects path traversal with ../ on existing sandbox', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
expect(() => guardPath('../escape', tmpDir)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects absolute path outside sandbox', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
expect(() => guardPath('/etc/passwd', tmpDir)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
58
apps/gateway/src/agent/tools/path-guard.ts
Normal file
58
apps/gateway/src/agent/tools/path-guard.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory.
|
||||
* Throws SandboxEscapeError if the resolved path is outside the sandbox.
|
||||
*
|
||||
* Uses realpathSync to resolve symlinks in the sandbox root. The user-supplied path
|
||||
* is checked for containment AFTER lexical resolution but BEFORE resolving any symlinks
|
||||
* within the user path — so symlink escape attempts are caught too.
|
||||
*
|
||||
* @param userPath - The path provided by the agent (may be relative or absolute)
|
||||
* @param sandboxDir - The allowed root directory (already validated on session creation)
|
||||
* @returns The resolved absolute path, guaranteed to be within sandboxDir
|
||||
*/
|
||||
export function guardPath(userPath: string, sandboxDir: string): string {
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxResolved = fs.realpathSync.native(sandboxDir);
|
||||
|
||||
// Normalize both paths to resolve any symlinks in the sandbox root itself.
|
||||
// For the user path, we check containment BEFORE resolving symlinks in the path
|
||||
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox)
|
||||
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a path without resolving symlinks in the user-provided portion.
|
||||
* Use for paths that may not exist yet (creates, writes).
|
||||
*
|
||||
* Performs a lexical containment check only using path.resolve.
|
||||
*/
|
||||
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxAbs = path.resolve(sandboxDir);
|
||||
|
||||
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export class SandboxEscapeError extends Error {
|
||||
constructor(
|
||||
public readonly userPath: string,
|
||||
public readonly sandboxDir: string,
|
||||
public readonly resolvedPath: string,
|
||||
) {
|
||||
super(
|
||||
`Path escape attempt blocked: "${userPath}" resolves to "${resolvedPath}" which is outside sandbox "${sandboxDir}"`,
|
||||
);
|
||||
this.name = 'SandboxEscapeError';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { resolve, relative } from 'node:path';
|
||||
import { guardPath, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
||||
@@ -68,22 +68,6 @@ function extractBaseCommand(command: string): string {
|
||||
return firstToken.split('/').pop() ?? firstToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a user-supplied cwd to within the sandbox directory.
|
||||
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
|
||||
* falls back to the sandbox directory itself.
|
||||
*/
|
||||
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
|
||||
if (!requestedCwd) return sandboxDir;
|
||||
const resolved = resolve(sandboxDir, requestedCwd);
|
||||
const rel = relative(sandboxDir, resolved);
|
||||
if (rel.startsWith('..') || rel.startsWith('/')) {
|
||||
// Escape attempt — fall back to sandbox root
|
||||
return sandboxDir;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function runCommand(
|
||||
command: string,
|
||||
options: { timeoutMs: number; cwd?: string },
|
||||
@@ -185,7 +169,21 @@ export function createShellTools(sandboxDir?: string): ToolDefinition[] {
|
||||
}
|
||||
|
||||
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 60_000);
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
let safeCwd: string;
|
||||
try {
|
||||
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await runCommand(command, {
|
||||
timeoutMs,
|
||||
|
||||
@@ -17,6 +17,9 @@ import { SkillsModule } from './skills/skills.module.js';
|
||||
import { PluginModule } from './plugin/plugin.module.js';
|
||||
import { McpModule } from './mcp/mcp.module.js';
|
||||
import { AdminModule } from './admin/admin.module.js';
|
||||
import { CommandsModule } from './commands/commands.module.js';
|
||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||
import { GCModule } from './gc/gc.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
@Module({
|
||||
@@ -38,6 +41,9 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
PluginModule,
|
||||
McpModule,
|
||||
AdminModule,
|
||||
PreferencesModule,
|
||||
CommandsModule,
|
||||
GCModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -28,4 +28,8 @@ export class ChatSocketMessageDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
modelId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import type { SetThinkingPayload, SlashCommandPayload } from '@mosaic/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||
@@ -37,6 +40,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
constructor(
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(AUTH) private readonly auth: Auth,
|
||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
@@ -54,6 +59,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
client.data.user = session.user;
|
||||
client.data.session = session.session;
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
|
||||
// Broadcast command manifest to the newly connected client
|
||||
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket): void {
|
||||
@@ -79,9 +87,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||
agentSession = await this.agentService.createSession(conversationId, {
|
||||
provider: data.provider,
|
||||
modelId: data.modelId,
|
||||
agentConfigId: data.agentId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -112,6 +123,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
// Track channel connection
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||
|
||||
// Send session info so the client knows the model/provider
|
||||
{
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
if (agentSession) {
|
||||
const piSession = agentSession.piSession;
|
||||
client.emit('session:info', {
|
||||
conversationId,
|
||||
provider: agentSession.provider,
|
||||
modelId: agentSession.modelId,
|
||||
thinkingLevel: piSession.thinkingLevel,
|
||||
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send acknowledgment
|
||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||
|
||||
@@ -130,6 +156,53 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('set:thinking')
|
||||
handleSetThinking(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: SetThinkingPayload,
|
||||
): void {
|
||||
const session = this.agentService.getSession(data.conversationId);
|
||||
if (!session) {
|
||||
client.emit('error', {
|
||||
conversationId: data.conversationId,
|
||||
error: 'No active session for this conversation.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validLevels = session.piSession.getAvailableThinkingLevels();
|
||||
if (!validLevels.includes(data.level as never)) {
|
||||
client.emit('error', {
|
||||
conversationId: data.conversationId,
|
||||
error: `Invalid thinking level "${data.level}". Available: ${validLevels.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
session.piSession.setThinkingLevel(data.level as never);
|
||||
this.logger.log(
|
||||
`Thinking level set to "${data.level}" for conversation ${data.conversationId}`,
|
||||
);
|
||||
|
||||
client.emit('session:info', {
|
||||
conversationId: data.conversationId,
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
thinkingLevel: session.piSession.thinkingLevel,
|
||||
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeMessage('command:execute')
|
||||
async handleCommandExecute(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SlashCommandPayload,
|
||||
): Promise<void> {
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
|
||||
const result = await this.commandExecutor.execute(payload, userId);
|
||||
client.emit('command:result', result);
|
||||
}
|
||||
|
||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||
if (!client.connected) {
|
||||
this.logger.warn(
|
||||
@@ -143,9 +216,31 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
client.emit('agent:start', { conversationId });
|
||||
break;
|
||||
|
||||
case 'agent_end':
|
||||
client.emit('agent:end', { conversationId });
|
||||
case 'agent_end': {
|
||||
// Gather usage stats from the Pi session
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
const piSession = agentSession?.piSession;
|
||||
const stats = piSession?.getSessionStats();
|
||||
const contextUsage = piSession?.getContextUsage();
|
||||
|
||||
client.emit('agent:end', {
|
||||
conversationId,
|
||||
usage: stats
|
||||
? {
|
||||
provider: agentSession?.provider ?? 'unknown',
|
||||
modelId: agentSession?.modelId ?? 'unknown',
|
||||
thinkingLevel: piSession?.thinkingLevel ?? 'off',
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
context: {
|
||||
percent: contextUsage?.percent ?? null,
|
||||
window: contextUsage?.contextWindow ?? 0,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'message_update': {
|
||||
const assistantEvent = event.assistantMessageEvent;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { ChatController } from './chat.controller.js';
|
||||
|
||||
@Module({
|
||||
imports: [CommandsModule],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatGateway],
|
||||
})
|
||||
|
||||
167
apps/gateway/src/commands/command-executor.service.ts
Normal file
167
apps/gateway/src/commands/command-executor.service.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class CommandExecutorService {
|
||||
private readonly logger = new Logger(CommandExecutorService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(CommandRegistryService) private readonly registry: CommandRegistryService,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
) {}
|
||||
|
||||
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||
const { command, args, conversationId } = payload;
|
||||
|
||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||
if (!def) {
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Unknown command: /${command}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'model':
|
||||
return await this.handleModel(args ?? null, conversationId);
|
||||
case 'thinking':
|
||||
return await this.handleThinking(args ?? null, conversationId);
|
||||
case 'system':
|
||||
return await this.handleSystem(args ?? null, conversationId);
|
||||
case 'new':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Start a new conversation by selecting New Conversation.',
|
||||
};
|
||||
case 'clear':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Conversation display cleared.',
|
||||
};
|
||||
case 'compact':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Context compaction requested.',
|
||||
};
|
||||
case 'retry':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Retry last message requested.',
|
||||
};
|
||||
case 'gc': {
|
||||
// User-scoped sweep for non-admin; system-wide for admin
|
||||
const result = await this.sessionGC.sweepOrphans(userId);
|
||||
return {
|
||||
command: 'gc',
|
||||
success: true,
|
||||
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Command /${command} is not yet implemented.`,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Command /${command} failed: ${err}`);
|
||||
return { command, conversationId, success: false, message: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async handleModel(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args) {
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Usage: /model <model-name>',
|
||||
};
|
||||
}
|
||||
// Update agent session model if session is active
|
||||
// For now, acknowledge the request — full wiring done in P8-012
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
if (!session) {
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Model switch to "${args}" requested. No active session for this conversation.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Model switch to "${args}" requested.`,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleThinking(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
const level = args?.toLowerCase();
|
||||
if (!level || !['none', 'low', 'medium', 'high', 'auto'].includes(level)) {
|
||||
return {
|
||||
command: 'thinking',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Usage: /thinking <none|low|medium|high|auto>',
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'thinking',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Thinking level set to "${level}".`,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleSystem(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args.trim().length === 0) {
|
||||
// Clear the override when called with no args
|
||||
await this.systemOverride.clear(conversationId);
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Session system prompt override cleared.',
|
||||
};
|
||||
}
|
||||
|
||||
await this.systemOverride.set(conversationId, args.trim());
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Session system prompt override set (expires in 5 minutes of inactivity).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
53
apps/gateway/src/commands/command-registry.service.spec.ts
Normal file
53
apps/gateway/src/commands/command-registry.service.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import type { CommandDef } from '@mosaic/types';
|
||||
|
||||
const mockCmd: CommandDef = {
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
aliases: ['t'],
|
||||
scope: 'core',
|
||||
execution: 'local',
|
||||
available: true,
|
||||
};
|
||||
|
||||
describe('CommandRegistryService', () => {
|
||||
let service: CommandRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CommandRegistryService();
|
||||
});
|
||||
|
||||
it('starts with empty manifest', () => {
|
||||
expect(service.getManifest().commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('registers a command', () => {
|
||||
service.registerCommand(mockCmd);
|
||||
expect(service.getManifest().commands).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updates existing command by name', () => {
|
||||
service.registerCommand(mockCmd);
|
||||
service.registerCommand({ ...mockCmd, description: 'Updated' });
|
||||
expect(service.getManifest().commands).toHaveLength(1);
|
||||
expect(service.getManifest().commands[0]?.description).toBe('Updated');
|
||||
});
|
||||
|
||||
it('onModuleInit registers core commands', () => {
|
||||
service.onModuleInit();
|
||||
const manifest = service.getManifest();
|
||||
expect(manifest.commands.length).toBeGreaterThan(5);
|
||||
expect(manifest.commands.some((c) => c.name === 'model')).toBe(true);
|
||||
expect(manifest.commands.some((c) => c.name === 'help')).toBe(true);
|
||||
});
|
||||
|
||||
it('manifest includes skills array', () => {
|
||||
const manifest = service.getManifest();
|
||||
expect(Array.isArray(manifest.skills)).toBe(true);
|
||||
});
|
||||
|
||||
it('manifest version is 1', () => {
|
||||
expect(service.getManifest().version).toBe(1);
|
||||
});
|
||||
});
|
||||
201
apps/gateway/src/commands/command-registry.service.ts
Normal file
201
apps/gateway/src/commands/command-registry.service.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import type { CommandDef, CommandManifest } from '@mosaic/types';
|
||||
|
||||
@Injectable()
|
||||
export class CommandRegistryService implements OnModuleInit {
|
||||
private readonly commands: CommandDef[] = [];
|
||||
|
||||
registerCommand(def: CommandDef): void {
|
||||
const existing = this.commands.findIndex((c) => c.name === def.name);
|
||||
if (existing >= 0) {
|
||||
this.commands[existing] = def;
|
||||
} else {
|
||||
this.commands.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
registerCommands(defs: CommandDef[]): void {
|
||||
for (const def of defs) {
|
||||
this.registerCommand(def);
|
||||
}
|
||||
}
|
||||
|
||||
getManifest(): CommandManifest {
|
||||
return {
|
||||
version: 1,
|
||||
commands: [...this.commands],
|
||||
skills: [],
|
||||
};
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.registerCommands([
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Switch the active model',
|
||||
aliases: ['m'],
|
||||
args: [
|
||||
{
|
||||
name: 'model-name',
|
||||
type: 'string',
|
||||
optional: false,
|
||||
description: 'Model name to switch to',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'thinking',
|
||||
description: 'Set thinking level (none/low/medium/high/auto)',
|
||||
aliases: ['t'],
|
||||
args: [
|
||||
{
|
||||
name: 'level',
|
||||
type: 'enum',
|
||||
optional: false,
|
||||
values: ['none', 'low', 'medium', 'high', 'auto'],
|
||||
description: 'Thinking level',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
aliases: ['n'],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Clear conversation context and GC session artifacts',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'compact',
|
||||
description: 'Request context compaction',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'retry',
|
||||
description: 'Retry the last message',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'rename',
|
||||
description: 'Rename current conversation',
|
||||
aliases: [],
|
||||
args: [
|
||||
{ name: 'name', type: 'string', optional: false, description: 'New conversation name' },
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
description: 'Show conversation history',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'string',
|
||||
optional: true,
|
||||
description: 'Number of messages to show',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'export',
|
||||
description: 'Export conversation to markdown or JSON',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'format',
|
||||
type: 'enum',
|
||||
optional: true,
|
||||
values: ['md', 'json'],
|
||||
description: 'Export format',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'preferences',
|
||||
description: 'View or set user preferences',
|
||||
aliases: ['pref'],
|
||||
args: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'enum',
|
||||
optional: true,
|
||||
values: ['show', 'set', 'reset'],
|
||||
description: 'Action to perform',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'system',
|
||||
description: 'Set session-scoped system prompt override',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'override',
|
||||
type: 'string',
|
||||
optional: false,
|
||||
description: 'System prompt text to inject for this session',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
description: 'Show session and connection status',
|
||||
aliases: ['s'],
|
||||
scope: 'core',
|
||||
execution: 'hybrid',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
aliases: ['h'],
|
||||
scope: 'core',
|
||||
execution: 'local',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'gc',
|
||||
description: 'Trigger garbage collection sweep (user-scoped)',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
11
apps/gateway/src/commands/commands.module.ts
Normal file
11
apps/gateway/src/commands/commands.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [GCModule],
|
||||
providers: [CommandRegistryService, CommandExecutorService],
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
})
|
||||
export class CommandsModule {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
31
apps/gateway/src/gc/gc.module.ts
Normal file
31
apps/gateway/src/gc/gc.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
import { REDIS } from './gc.tokens.js';
|
||||
|
||||
const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: GC_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
return createQueue();
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
inject: [GC_QUEUE_HANDLE],
|
||||
},
|
||||
SessionGCService,
|
||||
],
|
||||
exports: [SessionGCService],
|
||||
})
|
||||
export class GCModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
1
apps/gateway/src/gc/gc.tokens.ts
Normal file
1
apps/gateway/src/gc/gc.tokens.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const REDIS = 'REDIS';
|
||||
97
apps/gateway/src/gc/session-gc.service.spec.ts
Normal file
97
apps/gateway/src/gc/session-gc.service.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaic/queue';
|
||||
import type { LogService } from '@mosaic/log';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
|
||||
type MockRedis = {
|
||||
keys: ReturnType<typeof vi.fn>;
|
||||
del: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe('SessionGCService', () => {
|
||||
let service: SessionGCService;
|
||||
let mockRedis: MockRedis;
|
||||
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = {
|
||||
keys: vi.fn().mockResolvedValue([]),
|
||||
del: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
|
||||
mockLogService = {
|
||||
logs: {
|
||||
promoteToWarm: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
};
|
||||
|
||||
// Suppress logger output in tests
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
|
||||
|
||||
service = new SessionGCService(
|
||||
mockRedis as unknown as QueueHandle['redis'],
|
||||
mockLogService as unknown as LogService,
|
||||
);
|
||||
});
|
||||
|
||||
it('collect() deletes Valkey keys for session', async () => {
|
||||
mockRedis.keys.mockResolvedValue(['mosaic:session:abc:system', 'mosaic:session:abc:foo']);
|
||||
const result = await service.collect('abc');
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||
'mosaic:session:abc:system',
|
||||
'mosaic:session:abc:foo',
|
||||
);
|
||||
expect(result.cleaned.valkeyKeys).toBe(2);
|
||||
});
|
||||
|
||||
it('collect() with no keys returns empty cleaned valkeyKeys', async () => {
|
||||
mockRedis.keys.mockResolvedValue([]);
|
||||
const result = await service.collect('abc');
|
||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||
});
|
||||
|
||||
it('collect() returns sessionId in result', async () => {
|
||||
const result = await service.collect('test-session-id');
|
||||
expect(result.sessionId).toBe('test-session-id');
|
||||
});
|
||||
|
||||
it('fullCollect() deletes all session keys', async () => {
|
||||
mockRedis.keys.mockResolvedValue(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
|
||||
const result = await service.fullCollect();
|
||||
expect(mockRedis.del).toHaveBeenCalled();
|
||||
expect(result.valkeyKeys).toBe(2);
|
||||
});
|
||||
|
||||
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
|
||||
mockRedis.keys.mockResolvedValue([]);
|
||||
const result = await service.fullCollect();
|
||||
expect(result.valkeyKeys).toBe(0);
|
||||
expect(mockRedis.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fullCollect() returns duration', async () => {
|
||||
const result = await service.fullCollect();
|
||||
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('sweepOrphans() extracts unique session IDs and collects them', async () => {
|
||||
mockRedis.keys.mockResolvedValue([
|
||||
'mosaic:session:abc:system',
|
||||
'mosaic:session:abc:messages',
|
||||
'mosaic:session:xyz:system',
|
||||
]);
|
||||
mockRedis.del.mockResolvedValue(1);
|
||||
|
||||
const result = await service.sweepOrphans();
|
||||
expect(result.orphanedSessions).toBeGreaterThanOrEqual(0);
|
||||
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('sweepOrphans() returns empty when no session keys', async () => {
|
||||
mockRedis.keys.mockResolvedValue([]);
|
||||
const result = await service.sweepOrphans();
|
||||
expect(result.orphanedSessions).toBe(0);
|
||||
expect(result.totalCleaned).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
139
apps/gateway/src/gc/session-gc.service.ts
Normal file
139
apps/gateway/src/gc/session-gc.service.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaic/queue';
|
||||
import type { LogService } from '@mosaic/log';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
import { REDIS } from './gc.tokens.js';
|
||||
|
||||
export interface GCResult {
|
||||
sessionId: string;
|
||||
cleaned: {
|
||||
valkeyKeys?: number;
|
||||
logsDemoted?: number;
|
||||
tempFilesRemoved?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GCSweepResult {
|
||||
orphanedSessions: number;
|
||||
totalCleaned: GCResult[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface FullGCResult {
|
||||
valkeyKeys: number;
|
||||
logsDemoted: number;
|
||||
jobsPurged: number;
|
||||
tempFilesRemoved: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionGCService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SessionGCService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
this.logger.log('Running full GC on cold start...');
|
||||
const result = await this.fullCollect();
|
||||
this.logger.log(
|
||||
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
||||
`${result.logsDemoted} logs demoted, ` +
|
||||
`${result.jobsPurged} jobs purged, ` +
|
||||
`${result.tempFilesRemoved} temp dirs removed ` +
|
||||
`(${result.duration}ms)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediate cleanup for a single session (call from destroySession).
|
||||
*/
|
||||
async collect(sessionId: string): Promise<GCResult> {
|
||||
const result: GCResult = { sessionId, cleaned: {} };
|
||||
|
||||
// 1. Valkey: delete all session-scoped keys
|
||||
const pattern = `mosaic:session:${sessionId}:*`;
|
||||
const valkeyKeys = await this.redis.keys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
|
||||
// 2. PG: demote hot-tier agent_logs for this session to warm
|
||||
const cutoff = new Date(); // demote all hot logs for this session
|
||||
const logsDemoted = await this.logService.logs.promoteToWarm(cutoff);
|
||||
if (logsDemoted > 0) {
|
||||
result.cleaned.logsDemoted = logsDemoted;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep GC — find orphaned artifacts from dead sessions.
|
||||
* User-scoped when userId provided; system-wide when null (admin).
|
||||
*/
|
||||
async sweepOrphans(_userId?: string): Promise<GCSweepResult> {
|
||||
const start = Date.now();
|
||||
const cleaned: GCResult[] = [];
|
||||
|
||||
// 1. Find all session-scoped Valkey keys
|
||||
const allSessionKeys = await this.redis.keys('mosaic:session:*');
|
||||
|
||||
// Extract unique session IDs from keys
|
||||
const sessionIds = new Set<string>();
|
||||
for (const key of allSessionKeys) {
|
||||
const match = key.match(/^mosaic:session:([^:]+):/);
|
||||
if (match) sessionIds.add(match[1]!);
|
||||
}
|
||||
|
||||
// 2. For each session ID, collect stale keys
|
||||
for (const sessionId of sessionIds) {
|
||||
const gcResult = await this.collect(sessionId);
|
||||
if (Object.keys(gcResult.cleaned).length > 0) {
|
||||
cleaned.push(gcResult);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
orphanedSessions: cleaned.length,
|
||||
totalCleaned: cleaned,
|
||||
duration: Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full GC — aggressive collection for cold start.
|
||||
* Assumes no sessions survived the restart.
|
||||
*/
|
||||
async fullCollect(): Promise<FullGCResult> {
|
||||
const start = Date.now();
|
||||
|
||||
// 1. Valkey: delete ALL session-scoped keys
|
||||
const sessionKeys = await this.redis.keys('mosaic:session:*');
|
||||
if (sessionKeys.length > 0) {
|
||||
await this.redis.del(...sessionKeys);
|
||||
}
|
||||
|
||||
// 2. NOTE: channel keys are NOT collected on cold start
|
||||
// (discord/telegram plugins may reconnect and resume)
|
||||
|
||||
// 3. PG: demote stale hot-tier logs older than 24h to warm
|
||||
const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff);
|
||||
|
||||
// 4. No summarization job purge API available yet
|
||||
const jobsPurged = 0;
|
||||
|
||||
return {
|
||||
valkeyKeys: sessionKeys.length,
|
||||
logsDemoted,
|
||||
jobsPurged,
|
||||
tempFilesRemoved: 0,
|
||||
duration: Date.now() - start,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,22 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import cron from 'node-cron';
|
||||
import { SummarizationService } from './summarization.service.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly tasks: cron.ScheduledTask[] = [];
|
||||
|
||||
constructor(@Inject(SummarizationService) private readonly summarization: SummarizationService) {}
|
||||
constructor(
|
||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
|
||||
|
||||
this.tasks.push(
|
||||
cron.schedule(summarizationSchedule, () => {
|
||||
@@ -35,8 +40,16 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
}),
|
||||
);
|
||||
|
||||
this.tasks.push(
|
||||
cron.schedule(gcSchedule, () => {
|
||||
this.sessionGC.sweepOrphans().catch((err) => {
|
||||
this.logger.error(`Session GC sweep failed: ${err}`);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
||||
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { LOG_SERVICE } from './log.tokens.js';
|
||||
import { LogController } from './log.controller.js';
|
||||
import { SummarizationService } from './summarization.service.js';
|
||||
import { CronService } from './cron.service.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [GCModule],
|
||||
providers: [
|
||||
{
|
||||
provide: LOG_SERVICE,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
44
apps/gateway/src/preferences/preferences.controller.ts
Normal file
44
apps/gateway/src/preferences/preferences.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PreferencesService } from './preferences.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
|
||||
@Controller('api/preferences')
|
||||
@UseGuards(AuthGuard)
|
||||
export class PreferencesController {
|
||||
constructor(@Inject(PreferencesService) private readonly preferences: PreferencesService) {}
|
||||
|
||||
@Get()
|
||||
async show(@CurrentUser() user: { id: string }): Promise<Record<string, unknown>> {
|
||||
return this.preferences.getEffective(user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async set(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body() body: { key: string; value: unknown },
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return this.preferences.set(user.id, body.key, body.value);
|
||||
}
|
||||
|
||||
@Delete(':key')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async reset(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('key') key: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return this.preferences.reset(user.id, key);
|
||||
}
|
||||
}
|
||||
12
apps/gateway/src/preferences/preferences.module.ts
Normal file
12
apps/gateway/src/preferences/preferences.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PreferencesService } from './preferences.service.js';
|
||||
import { PreferencesController } from './preferences.controller.js';
|
||||
import { SystemOverrideService } from './system-override.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [PreferencesController],
|
||||
providers: [PreferencesService, SystemOverrideService],
|
||||
exports: [PreferencesService, SystemOverrideService],
|
||||
})
|
||||
export class PreferencesModule {}
|
||||
167
apps/gateway/src/preferences/preferences.service.spec.ts
Normal file
167
apps/gateway/src/preferences/preferences.service.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PreferencesService, PLATFORM_DEFAULTS, IMMUTABLE_KEYS } from './preferences.service.js';
|
||||
import type { Db } from '@mosaic/db';
|
||||
|
||||
/**
|
||||
* Build a mock Drizzle DB where the select chain supports:
|
||||
* db.select().from().where() → resolves to `listRows`
|
||||
* db.select().from().where().limit(n) → resolves to `singleRow`
|
||||
*/
|
||||
function makeMockDb(
|
||||
listRows: Array<{ key: string; value: unknown }> = [],
|
||||
singleRow: Array<{ id: string }> = [],
|
||||
): Db {
|
||||
const chainWithLimit = {
|
||||
limit: vi.fn().mockResolvedValue(singleRow),
|
||||
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
|
||||
};
|
||||
const selectFrom = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnValue(chainWithLimit),
|
||||
};
|
||||
const updateResult = {
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const deleteResult = {
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const insertResult = {
|
||||
values: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
return {
|
||||
select: vi.fn().mockReturnValue(selectFrom),
|
||||
update: vi.fn().mockReturnValue(updateResult),
|
||||
delete: vi.fn().mockReturnValue(deleteResult),
|
||||
insert: vi.fn().mockReturnValue(insertResult),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
describe('PreferencesService', () => {
|
||||
describe('getEffective', () => {
|
||||
it('returns platform defaults when user has no overrides', async () => {
|
||||
const db = makeMockDb([]);
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.getEffective('user-1');
|
||||
|
||||
expect(result['agent.thinkingLevel']).toBe('auto');
|
||||
expect(result['agent.streamingEnabled']).toBe(true);
|
||||
expect(result['session.autoCompactEnabled']).toBe(true);
|
||||
expect(result['session.autoCompactThreshold']).toBe(0.8);
|
||||
});
|
||||
|
||||
it('applies user overrides for mutable keys', async () => {
|
||||
const db = makeMockDb([
|
||||
{ key: 'agent.thinkingLevel', value: 'high' },
|
||||
{ key: 'response.language', value: 'es' },
|
||||
]);
|
||||
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.getEffective('user-1');
|
||||
|
||||
expect(result['agent.thinkingLevel']).toBe('high');
|
||||
expect(result['response.language']).toBe('es');
|
||||
});
|
||||
|
||||
it('ignores user overrides for immutable keys — enforcement always wins', async () => {
|
||||
const db = makeMockDb([
|
||||
{ key: 'limits.maxThinkingLevel', value: 'high' },
|
||||
{ key: 'limits.rateLimit', value: 9999 },
|
||||
]);
|
||||
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.getEffective('user-1');
|
||||
|
||||
// Should still be null (platform default), not the user-supplied values
|
||||
expect(result['limits.maxThinkingLevel']).toBeNull();
|
||||
expect(result['limits.rateLimit']).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('returns error when attempting to override an immutable key', async () => {
|
||||
const db = makeMockDb();
|
||||
const service = new PreferencesService(db);
|
||||
|
||||
const result = await service.set('user-1', 'limits.maxThinkingLevel', 'high');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('platform enforcement');
|
||||
});
|
||||
|
||||
it('returns error when attempting to override limits.rateLimit', async () => {
|
||||
const db = makeMockDb();
|
||||
const service = new PreferencesService(db);
|
||||
|
||||
const result = await service.set('user-1', 'limits.rateLimit', 100);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('platform enforcement');
|
||||
});
|
||||
|
||||
it('upserts a mutable preference and returns success — insert path', async () => {
|
||||
// singleRow=[] → no existing row → insert path
|
||||
const db = makeMockDb([], []);
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('"agent.thinkingLevel"');
|
||||
});
|
||||
|
||||
it('upserts a mutable preference and returns success — update path', async () => {
|
||||
// singleRow has an id → existing row → update path
|
||||
const db = makeMockDb([], [{ id: 'existing-id' }]);
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.set('user-1', 'agent.thinkingLevel', 'low');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('"agent.thinkingLevel"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('returns error when attempting to reset an immutable key', async () => {
|
||||
const db = makeMockDb();
|
||||
const service = new PreferencesService(db);
|
||||
|
||||
const result = await service.reset('user-1', 'limits.rateLimit');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('platform enforcement');
|
||||
});
|
||||
|
||||
it('deletes user override and returns default value in message', async () => {
|
||||
const db = makeMockDb();
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.reset('user-1', 'agent.thinkingLevel');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('"auto"'); // platform default for agent.thinkingLevel
|
||||
});
|
||||
});
|
||||
|
||||
describe('IMMUTABLE_KEYS', () => {
|
||||
it('contains only the enforcement keys', () => {
|
||||
expect(IMMUTABLE_KEYS.has('limits.maxThinkingLevel')).toBe(true);
|
||||
expect(IMMUTABLE_KEYS.has('limits.rateLimit')).toBe(true);
|
||||
expect(IMMUTABLE_KEYS.has('agent.thinkingLevel')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PLATFORM_DEFAULTS', () => {
|
||||
it('has all expected keys', () => {
|
||||
const expectedKeys = [
|
||||
'agent.defaultModel',
|
||||
'agent.thinkingLevel',
|
||||
'agent.streamingEnabled',
|
||||
'response.language',
|
||||
'response.codeAnnotations',
|
||||
'safety.confirmDestructiveTools',
|
||||
'session.autoCompactThreshold',
|
||||
'session.autoCompactEnabled',
|
||||
'limits.maxThinkingLevel',
|
||||
'limits.rateLimit',
|
||||
];
|
||||
for (const key of expectedKeys) {
|
||||
expect(Object.prototype.hasOwnProperty.call(PLATFORM_DEFAULTS, key)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
119
apps/gateway/src/preferences/preferences.service.ts
Normal file
119
apps/gateway/src/preferences/preferences.service.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, type Db, preferences as preferencesTable } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
export const PLATFORM_DEFAULTS: Record<string, unknown> = {
|
||||
'agent.defaultModel': null,
|
||||
'agent.thinkingLevel': 'auto',
|
||||
'agent.streamingEnabled': true,
|
||||
'response.language': 'auto',
|
||||
'response.codeAnnotations': true,
|
||||
'safety.confirmDestructiveTools': true,
|
||||
'session.autoCompactThreshold': 0.8,
|
||||
'session.autoCompactEnabled': true,
|
||||
'limits.maxThinkingLevel': null,
|
||||
'limits.rateLimit': null,
|
||||
};
|
||||
|
||||
export const IMMUTABLE_KEYS = new Set<string>(['limits.maxThinkingLevel', 'limits.rateLimit']);
|
||||
|
||||
@Injectable()
|
||||
export class PreferencesService {
|
||||
private readonly logger = new Logger(PreferencesService.name);
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/**
|
||||
* Returns the effective preference set for a user:
|
||||
* Platform defaults → user overrides (mutable keys only) → enforcements re-applied last
|
||||
*/
|
||||
async getEffective(userId: string): Promise<Record<string, unknown>> {
|
||||
const userPrefs = await this.getUserPrefs(userId);
|
||||
const result: Record<string, unknown> = { ...PLATFORM_DEFAULTS };
|
||||
|
||||
for (const [key, value] of Object.entries(userPrefs)) {
|
||||
if (!IMMUTABLE_KEYS.has(key)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-apply immutable keys (enforcements always win)
|
||||
for (const key of IMMUTABLE_KEYS) {
|
||||
result[key] = PLATFORM_DEFAULTS[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async set(
|
||||
userId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
if (IMMUTABLE_KEYS.has(key)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Cannot override "${key}" — this is a platform enforcement. Contact your admin.`,
|
||||
};
|
||||
}
|
||||
|
||||
await this.upsertPref(userId, key, value);
|
||||
return { success: true, message: `Preference "${key}" set to ${JSON.stringify(value)}.` };
|
||||
}
|
||||
|
||||
async reset(userId: string, key: string): Promise<{ success: boolean; message: string }> {
|
||||
if (IMMUTABLE_KEYS.has(key)) {
|
||||
return { success: false, message: `Cannot reset "${key}" — it is a platform enforcement.` };
|
||||
}
|
||||
|
||||
await this.deletePref(userId, key);
|
||||
const defaultVal = PLATFORM_DEFAULTS[key];
|
||||
return {
|
||||
success: true,
|
||||
message: `Preference "${key}" reset to default: ${JSON.stringify(defaultVal)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
private async getUserPrefs(userId: string): Promise<Record<string, unknown>> {
|
||||
const rows = await this.db
|
||||
.select({ key: preferencesTable.key, value: preferencesTable.value })
|
||||
.from(preferencesTable)
|
||||
.where(eq(preferencesTable.userId, userId));
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
result[row.key] = row.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async upsertPref(userId: string, key: string, value: unknown): Promise<void> {
|
||||
const existing = await this.db
|
||||
.select({ id: preferencesTable.id })
|
||||
.from(preferencesTable)
|
||||
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await this.db
|
||||
.update(preferencesTable)
|
||||
.set({ value: value as never, updatedAt: new Date() })
|
||||
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
|
||||
} else {
|
||||
await this.db.insert(preferencesTable).values({
|
||||
userId,
|
||||
key,
|
||||
value: value as never,
|
||||
mutable: true,
|
||||
});
|
||||
}
|
||||
this.logger.debug(`Upserted preference "${key}" for user ${userId}`);
|
||||
}
|
||||
|
||||
private async deletePref(userId: string, key: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(preferencesTable)
|
||||
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
|
||||
this.logger.debug(`Deleted preference "${key}" for user ${userId}`);
|
||||
}
|
||||
}
|
||||
33
apps/gateway/src/preferences/system-override.service.ts
Normal file
33
apps/gateway/src/preferences/system-override.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||
|
||||
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
||||
const TTL_SECONDS = 5 * 60; // 5 minutes, renewed on each turn
|
||||
|
||||
@Injectable()
|
||||
export class SystemOverrideService {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle;
|
||||
|
||||
constructor() {
|
||||
this.handle = createQueue();
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string): Promise<void> {
|
||||
await this.handle.redis.setex(SESSION_SYSTEM_KEY(sessionId), TTL_SECONDS, override);
|
||||
this.logger.debug(`Set system override for session ${sessionId} (TTL=${TTL_SECONDS}s)`);
|
||||
}
|
||||
|
||||
async get(sessionId: string): Promise<string | null> {
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
||||
}
|
||||
|
||||
async renew(sessionId: string): Promise<void> {
|
||||
await this.handle.redis.expire(SESSION_SYSTEM_KEY(sessionId), TTL_SECONDS);
|
||||
}
|
||||
|
||||
async clear(sessionId: string): Promise<void> {
|
||||
await this.handle.redis.del(SESSION_SYSTEM_KEY(sessionId));
|
||||
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
|
||||
@@ -4,5 +4,6 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | done | — | #99 | 2026-03-14 | 2026-03-14 |
|
||||
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | done | — | #104 | 2026-03-14 | 2026-03-14 |
|
||||
| 7 | ms-164 | Phase 7: Feature Completion (v0.0.8) | done | — | — | 2026-03-15 | 2026-03-15 |
|
||||
| 8 | ms-165 | Phase 8: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
|
||||
| 8 | ms-165 | Phase 8: Polish & Beta (v0.1.0) | in-progress | — | — | 2026-03-15 | — |
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
70
docs/PRD-TUI_Improvements.md
Normal file
70
docs/PRD-TUI_Improvements.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# PRD: TUI Improvements — Phase 7
|
||||
|
||||
**Branch:** `feat/p7-tui-improvements`
|
||||
**Package:** `packages/cli`
|
||||
**Status:** In Progress
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current Mosaic CLI TUI (`packages/cli/src/tui/app.tsx`) is a minimal single-file Ink application with:
|
||||
|
||||
- Flat message list with no visual hierarchy
|
||||
- No system context visibility (cwd, branch, model, tokens)
|
||||
- Noisy error messages when gateway is disconnected
|
||||
- No conversation management (list, switch, rename, delete)
|
||||
- No multi-panel layout or navigation
|
||||
- No tool call visibility during agent execution
|
||||
- No thinking/reasoning display
|
||||
|
||||
The TUI should be the power-user interface to Mosaic — informative, responsive, and visually clean.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
### Wave 1 — Status Bar & Polish (MVP)
|
||||
|
||||
Provide essential context at a glance and reduce noise.
|
||||
|
||||
1. **Top status bar** — shows: connection indicator (●/○), gateway URL, agent model name
|
||||
2. **Bottom status bar** — shows: cwd, git branch, token usage (input/output/total)
|
||||
3. **Better message formatting** — distinct visual treatment for user vs assistant messages, timestamps, word wrap
|
||||
4. **Quiet disconnect** — single-line indicator when gateway is offline instead of flooding error messages; auto-reconnect silently
|
||||
5. **Tool call display** — inline indicators when agent uses tools (spinner + tool name during execution, ✓/✗ on completion)
|
||||
6. **Thinking/reasoning display** — collapsible dimmed block for `agent:thinking` events
|
||||
|
||||
### Wave 2 — Layout & Navigation
|
||||
|
||||
Multi-panel layout with keyboard navigation.
|
||||
|
||||
1. **Conversation sidebar** — list conversations, create new, switch between them
|
||||
2. **Keybinding system** — Ctrl+N (new conversation), Ctrl+L (conversation list toggle), Ctrl+K (command palette concept)
|
||||
3. **Scrollable message history** — viewport with PgUp/PgDn/arrow key scrolling
|
||||
4. **Message search** — find in current conversation
|
||||
|
||||
### Wave 3 — Advanced Features
|
||||
|
||||
1. **Project/mission views** — show active projects, missions, tasks
|
||||
2. **Agent status monitoring** — real-time agent state, queue depth
|
||||
3. **Settings/config screen** — view/edit connection settings, model preferences
|
||||
4. **Multiple agent sessions** — split view or tab-based multi-agent
|
||||
|
||||
---
|
||||
|
||||
## Technical Approach
|
||||
|
||||
- **Ink 5** (React for CLI) — already in deps
|
||||
- **Component architecture** — break monolithic `app.tsx` into composable components
|
||||
- **Typed Socket.IO events** — leverage `@mosaic/types` `ServerToClientEvents` / `ClientToServerEvents`
|
||||
- **Local state only** (Wave 1) — cwd/branch read from `process.cwd()` and `git` at startup
|
||||
- **Gateway metadata** (future) — extend socket handshake or add REST endpoint for model info, token usage
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals (for now)
|
||||
|
||||
- Image rendering in terminal
|
||||
- File editor integration
|
||||
- SSH/remote gateway auto-discovery
|
||||
105
docs/TASKS-TUI_Improvements.md
Normal file
105
docs/TASKS-TUI_Improvements.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Tasks: TUI Improvements
|
||||
|
||||
**Branch:** `feat/p7-tui-improvements`
|
||||
**Worktree:** `/home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements`
|
||||
**PRD:** [PRD-TUI_Improvements.md](./PRD-TUI_Improvements.md)
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — Status Bar & Polish ✅
|
||||
|
||||
| ID | Task | Status | Notes |
|
||||
| -------- | ----------------------------------------------------------------------------------------------------- | ------- | ------- |
|
||||
| TUI-001 | Component architecture — split `app.tsx` into `TopBar`, `BottomBar`, `MessageList`, `InputBar`, hooks | ✅ done | 79ff308 |
|
||||
| TUI-002 | Top status bar — branded mosaic icon, version, model, connection indicator | ✅ done | 6c2b01e |
|
||||
| TUI-003 | Bottom status bar — cwd, git branch, token usage, session ID, gateway status | ✅ done | e8d7ab8 |
|
||||
| TUI-004 | Message formatting — timestamps, role colors (❯ you / ◆ assistant), word wrap | ✅ done | 79ff308 |
|
||||
| TUI-005 | Quiet disconnect — single indicator, auto-reconnect, no error flood | ✅ done | 79ff308 |
|
||||
| TUI-006 | Tool call display — inline spinner + tool name during execution, ✓/✗ on completion | ✅ done | 79ff308 |
|
||||
| TUI-007 | Thinking/reasoning display — dimmed 💭 block for `agent:thinking` events | ✅ done | 79ff308 |
|
||||
| TUI-007b | Wire token usage, model info, thinking levels end-to-end (gateway → types → TUI) | ✅ done | a061a64 |
|
||||
| TUI-007c | Ctrl+T to cycle thinking levels via `set:thinking` socket event | ✅ done | a061a64 |
|
||||
|
||||
## Wave 2 — Layout & Navigation ✅
|
||||
|
||||
| ID | Task | Status | Notes |
|
||||
| ------- | --------------------------------------------------------- | ------- | ------- |
|
||||
| TUI-010 | Scrollable message history — viewport with PgUp/PgDn | ✅ done | 4d4ad38 |
|
||||
| TUI-008 | Conversation sidebar — list, create, switch conversations | ✅ done | 9ef578c |
|
||||
| TUI-009 | Keybinding system — Ctrl+L, Ctrl+N, Ctrl+K, Escape | ✅ done | 9f38f5a |
|
||||
| TUI-011 | Message search — find in current conversation | ✅ done | 8627827 |
|
||||
|
||||
## Wave 3 — Advanced Features
|
||||
|
||||
| ID | Task | Status | Notes |
|
||||
| ------- | ----------------------- | ----------- | ----- |
|
||||
| TUI-012 | Project/mission views | not-started | |
|
||||
| TUI-013 | Agent status monitoring | not-started | |
|
||||
| TUI-014 | Settings/config screen | not-started | |
|
||||
| TUI-015 | Multiple agent sessions | not-started | |
|
||||
|
||||
---
|
||||
|
||||
## Handoff Notes
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
packages/cli/src/tui/
|
||||
├── app.tsx ← Shell composing all components + global keybindings
|
||||
├── components/
|
||||
│ ├── top-bar.tsx ← Mosaic icon + version + model + connection
|
||||
│ ├── bottom-bar.tsx ← Keybinding hints + 3-line footer: gateway, cwd, tokens
|
||||
│ ├── message-list.tsx ← Messages, tool calls, thinking, streaming, search highlights
|
||||
│ ├── input-bar.tsx ← Bordered prompt with context-aware placeholder
|
||||
│ ├── sidebar.tsx ← Conversation list with keyboard navigation
|
||||
│ └── search-bar.tsx ← Message search input with match count + navigation
|
||||
└── hooks/
|
||||
├── use-socket.ts ← Typed Socket.IO + switchConversation/clearMessages
|
||||
├── use-git-info.ts ← Reads cwd + git branch at startup
|
||||
├── use-viewport.ts ← Scrollable viewport with auto-follow + PgUp/PgDn
|
||||
├── use-app-mode.ts ← Panel focus state machine (chat/sidebar/search)
|
||||
├── use-conversations.ts ← REST client for conversation CRUD
|
||||
└── use-search.ts ← Message search with match cycling
|
||||
```
|
||||
|
||||
### Cross-Package Changes
|
||||
|
||||
- **`packages/types/src/chat/events.ts`** — Added `SessionUsagePayload`, `SessionInfoPayload`, `SetThinkingPayload`, `session:info` event, `set:thinking` event
|
||||
- **`apps/gateway/src/chat/chat.gateway.ts`** — Emits `session:info` on session creation, includes `usage` in `agent:end`, handles `set:thinking`
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
#### Wave 1
|
||||
|
||||
- Footer is 3 lines: (1) gateway status right-aligned, (2) cwd+branch left / session right, (3) tokens left / provider+model+thinking right
|
||||
- Mosaic icon uses brand colors in windmill cross pattern with `GAP` const to prevent prettier collapsing spaces
|
||||
- `flexGrow={1}` on header text column prevents re-render artifacts
|
||||
- Token/model data comes from gateway via `agent:end` payload and `session:info` events
|
||||
- Thinking level cycling via Ctrl+T sends `set:thinking` to gateway, which validates and responds with `session:info`
|
||||
|
||||
#### Wave 2
|
||||
|
||||
- `useViewport` calculates scroll offset from terminal rows; auto-follow snaps to bottom on new messages
|
||||
- `useAppMode` state machine manages focus: only the active panel handles keyboard input via `useInput({ isActive })`
|
||||
- Sidebar fetches conversations via REST (`GET /api/conversations`), not socket events
|
||||
- `switchConversation` in `useSocket` clears all local state (messages, streaming, tool calls)
|
||||
- Search uses `useMemo` for reactive match computation; viewport auto-scrolls to current match
|
||||
- Keybinding hints shown in bottom bar: `^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll`
|
||||
|
||||
### How to Run
|
||||
|
||||
```bash
|
||||
cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements
|
||||
pnpm --filter @mosaic/cli exec tsx src/cli.ts tui
|
||||
# or after build:
|
||||
node packages/cli/dist/cli.js tui --gateway http://localhost:4000
|
||||
```
|
||||
|
||||
### Quality Gates
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaic/cli typecheck && pnpm --filter @mosaic/cli lint
|
||||
pnpm --filter @mosaic/gateway typecheck && pnpm --filter @mosaic/gateway lint
|
||||
pnpm --filter @mosaic/types typecheck
|
||||
```
|
||||
169
docs/TASKS.md
169
docs/TASKS.md
@@ -2,80 +2,95 @@
|
||||
|
||||
> 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 |
|
||||
| P0-004 | done | Phase 0 | @mosaic/auth — BetterAuth email/password setup | #68 | #4 |
|
||||
| P0-005 | done | Phase 0 | Docker Compose — PG 17, Valkey 8, SigNoz | #65 | #5 |
|
||||
| P0-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
|
||||
| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 |
|
||||
| P0-008 | done | Phase 0 | Project docs — AGENTS.md, CLAUDE.md, README | #69 | #8 |
|
||||
| P0-009 | done | Phase 0 | Verify Phase 0 — CI green, all packages build | #70 | #9 |
|
||||
| P1-001 | done | Phase 1 | apps/gateway scaffold — NestJS + Fastify adapter | #61 | #10 |
|
||||
| P1-002 | done | Phase 1 | Auth middleware — BetterAuth session validation | #71 | #11 |
|
||||
| P1-003 | done | Phase 1 | @mosaic/brain — migrate from v0, PG backend | #71 | #12 |
|
||||
| P1-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
|
||||
| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 |
|
||||
| P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
|
||||
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
||||
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
|
||||
| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 |
|
||||
| P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 |
|
||||
| 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 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 |
|
||||
| P2-006 | done | Phase 2 | Agent session management — tmux + monitoring | #78 | #24 |
|
||||
| P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 |
|
||||
| P3-001 | done | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | #82 | #26 |
|
||||
| P3-002 | done | Phase 3 | Auth pages — login, registration, SSO redirect | #83 | #27 |
|
||||
| P3-003 | done | Phase 3 | Chat UI — conversations, messages, streaming | #84 | #28 |
|
||||
| P3-004 | done | Phase 3 | Task management — list view + kanban board | #86 | #29 |
|
||||
| P3-005 | done | Phase 3 | Project & mission views — dashboard + PRD viewer | #87 | #30 |
|
||||
| P3-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
|
||||
| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 |
|
||||
| P3-008 | done | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 |
|
||||
| P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
|
||||
| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 |
|
||||
| P4-003 | done | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 |
|
||||
| P4-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
|
||||
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
||||
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
||||
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
|
||||
| P5-001 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
|
||||
| P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 |
|
||||
| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
||||
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
||||
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
|
||||
| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 |
|
||||
| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 |
|
||||
| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 |
|
||||
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
|
||||
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
||||
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
||||
| P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done |
|
||||
| P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done |
|
||||
| P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done |
|
||||
| P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done |
|
||||
| P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done |
|
||||
| P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done |
|
||||
| P7-012 | done | Phase 7 | Web provider management UI — add, configure, test LLM providers | #142 | #123 W4 done |
|
||||
| P7-017 | done | Phase 7 | Agent skill invocation — load and execute skills from catalog | #143 | #128 W4 done |
|
||||
| P7-013 | done | Phase 7 | Web settings persistence — profile, preferences save to DB | #145 | #124 W5 done |
|
||||
| P7-018 | done | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | #144 | #129 W5 done |
|
||||
| P7-014 | done | Phase 7 | Web admin panel — user CRUD, role assignment, system health | #150 | #125 W6 done |
|
||||
| P7-019 | done | Phase 7 | CLI session management — list, resume, destroy sessions | #146 | #130 W6 done |
|
||||
| P7-020 | done | Phase 7 | Coord DB migration — project-scoped missions, multi-tenant RBAC | #149 | #131 W7 done |
|
||||
| FIX-02 | done | Backlog | TUI agent:end — fix React state updater side-effect | #147 | #133 W8 done |
|
||||
| FIX-03 | done | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | #148 | #134 W8 done |
|
||||
| P7-004 | done | Phase 7 | E2E test suite — Playwright critical paths | #152 | #55 W9 done |
|
||||
| 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-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 |
|
||||
| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
||||
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
||||
| 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 |
|
||||
| P0-004 | done | Phase 0 | @mosaic/auth — BetterAuth email/password setup | #68 | #4 |
|
||||
| P0-005 | done | Phase 0 | Docker Compose — PG 17, Valkey 8, SigNoz | #65 | #5 |
|
||||
| P0-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
|
||||
| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 |
|
||||
| P0-008 | done | Phase 0 | Project docs — AGENTS.md, CLAUDE.md, README | #69 | #8 |
|
||||
| P0-009 | done | Phase 0 | Verify Phase 0 — CI green, all packages build | #70 | #9 |
|
||||
| P1-001 | done | Phase 1 | apps/gateway scaffold — NestJS + Fastify adapter | #61 | #10 |
|
||||
| P1-002 | done | Phase 1 | Auth middleware — BetterAuth session validation | #71 | #11 |
|
||||
| P1-003 | done | Phase 1 | @mosaic/brain — migrate from v0, PG backend | #71 | #12 |
|
||||
| P1-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
|
||||
| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 |
|
||||
| P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
|
||||
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
||||
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
|
||||
| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 |
|
||||
| P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 |
|
||||
| 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 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 |
|
||||
| P2-006 | done | Phase 2 | Agent session management — tmux + monitoring | #78 | #24 |
|
||||
| P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 |
|
||||
| P3-001 | done | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | #82 | #26 |
|
||||
| P3-002 | done | Phase 3 | Auth pages — login, registration, SSO redirect | #83 | #27 |
|
||||
| P3-003 | done | Phase 3 | Chat UI — conversations, messages, streaming | #84 | #28 |
|
||||
| P3-004 | done | Phase 3 | Task management — list view + kanban board | #86 | #29 |
|
||||
| P3-005 | done | Phase 3 | Project & mission views — dashboard + PRD viewer | #87 | #30 |
|
||||
| P3-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
|
||||
| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 |
|
||||
| P3-008 | done | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 |
|
||||
| P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
|
||||
| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 |
|
||||
| P4-003 | done | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 |
|
||||
| P4-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
|
||||
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
||||
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
||||
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
|
||||
| P5-001 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
|
||||
| P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 |
|
||||
| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
||||
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
||||
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
|
||||
| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 |
|
||||
| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 |
|
||||
| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 |
|
||||
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
|
||||
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
||||
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
||||
| P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done |
|
||||
| P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done |
|
||||
| P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done |
|
||||
| P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done |
|
||||
| P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done |
|
||||
| P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done |
|
||||
| P7-012 | done | Phase 7 | Web provider management UI — add, configure, test LLM providers | #142 | #123 W4 done |
|
||||
| P7-017 | done | Phase 7 | Agent skill invocation — load and execute skills from catalog | #143 | #128 W4 done |
|
||||
| P7-013 | done | Phase 7 | Web settings persistence — profile, preferences save to DB | #145 | #124 W5 done |
|
||||
| P7-018 | done | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | #144 | #129 W5 done |
|
||||
| P7-014 | done | Phase 7 | Web admin panel — user CRUD, role assignment, system health | #150 | #125 W6 done |
|
||||
| P7-019 | done | Phase 7 | CLI session management — list, resume, destroy sessions | #146 | #130 W6 done |
|
||||
| P7-020 | done | Phase 7 | Coord DB migration — project-scoped missions, multi-tenant RBAC | #149 | #131 W7 done |
|
||||
| FIX-02 | done | Backlog | TUI agent:end — fix React state updater side-effect | #147 | #133 W8 done |
|
||||
| FIX-03 | done | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | #148 | #134 W8 done |
|
||||
| P7-004 | done | Phase 7 | E2E test suite — Playwright critical paths | #152 | #55 W9 done |
|
||||
| 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 | done | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | #158 | |
|
||||
| P8-006 | done | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | #158 | |
|
||||
| P8-007 | done | Phase 8 | DB migrations — preferences.mutable + teams + team_members + projects.teamId | #175 | #160 |
|
||||
| P8-008 | done | Phase 8 | @mosaic/types — CommandDef, CommandManifest, new socket events | #174 | #161 |
|
||||
| P8-009 | not-started | Phase 8 | TUI Phase 1 — slash command parsing, local commands, system message rendering, InputBar wiring | — | #162 |
|
||||
| P8-010 | not-started | Phase 8 | Gateway Phase 2 — CommandRegistryService, CommandExecutorService, socket + REST commands | — | #163 |
|
||||
| P8-011 | not-started | Phase 8 | Gateway Phase 3 — PreferencesService, /preferences REST, /system Valkey override, prompt injection | — | #164 |
|
||||
| P8-012 | not-started | Phase 8 | Gateway Phase 4 — /agent, /provider (URL+clipboard), /mission, /prdy, /tools commands | — | #165 |
|
||||
| P8-013 | not-started | Phase 8 | Gateway Phase 5 — MosaicPlugin lifecycle, ReloadService, hot reload, system:reload TUI | — | #166 |
|
||||
| P8-014 | not-started | Phase 8 | Gateway Phase 6 — SessionGCService (all tiers), /gc command, cron integration | — | #167 |
|
||||
| P8-015 | not-started | Phase 8 | Gateway Phase 7 — WorkspaceService, ProjectBootstrapService, teams project ownership | — | #168 |
|
||||
| P8-016 | done | Phase 8 | Security — file/git/shell tool strict path hardening, sandbox escape prevention | — | #169 |
|
||||
| P8-017 | not-started | Phase 8 | TUI Phase 8 — autocomplete sidebar, fuzzy match, arg hints, up-arrow history | — | #170 |
|
||||
| P8-018 | done | Phase 8 | Spin-off plan stubs — Gatekeeper, Task Queue Unification, Chroot Sandboxing | — | #171 |
|
||||
| P8-019 | not-started | Phase 8 | Verify Platform Architecture — integration + E2E verification | — | #172 |
|
||||
| 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 |
|
||||
| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
||||
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
||||
|
||||
1572
docs/plans/2026-03-15-agent-platform-architecture.md
Normal file
1572
docs/plans/2026-03-15-agent-platform-architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
1000
docs/plans/2026-03-15-wave2-tui-layout-navigation.md
Normal file
1000
docs/plans/2026-03-15-wave2-tui-layout-navigation.md
Normal file
File diff suppressed because it is too large
Load Diff
60
docs/plans/chroot-sandboxing.md
Normal file
60
docs/plans/chroot-sandboxing.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Chroot Agent Sandboxing — Process Isolation for Agent Tool Execution
|
||||
|
||||
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Phase 7 Workspaces → Chroot Agent Sandboxing).
|
||||
> Implement after Workspaces (P8-015) is complete. Requires workspace directory structure and `WorkspaceService` to be operational.
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Packages:** `apps/gateway`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Agent sessions can use file, git, and shell tools. Path validation in tools is defense-in-depth but insufficient alone — an agent with shell access can run `cat /opt/mosaic/.workspaces/other_user/...` and bypass gateway RBAC.
|
||||
|
||||
Chroot provides OS-level enforcement: tool processes literally cannot see outside their workspace directory.
|
||||
|
||||
---
|
||||
|
||||
## Design (Sweet Spot)
|
||||
|
||||
Chroot strikes the balance between full container isolation (too heavy per session) and path validation only (escape-prone):
|
||||
|
||||
- Gateway spawns tool processes inside a chroot rooted at the session's `sandboxDir`
|
||||
- Requires `CAP_SYS_CHROOT` capability on the gateway process (not full root)
|
||||
- Chroot environment provisioned by `WorkspaceService` on workspace creation (minimal deps: git, shell utils, language runtimes as needed)
|
||||
- Alternative for Docker deployments: Linux `unshare` namespaces (lighter, no chroot env setup)
|
||||
|
||||
---
|
||||
|
||||
## Scope (To Be Designed)
|
||||
|
||||
- [ ] Chroot environment provisioning — `WorkspaceService.provisionChroot(workspacePath)` on project creation
|
||||
- [ ] Minimal chroot deps — identify required binaries/libs per tool type (file: none; git: git binary; shell: bash, common utils)
|
||||
- [ ] Gateway capability — document `CAP_SYS_CHROOT` requirement; Dockerfile and docker-compose.yml changes
|
||||
- [ ] Tool process spawning — modify `createShellTools`, `createFileTools`, `createGitTools` to spawn via chroot wrapper
|
||||
- [ ] Docker alternative — `unshare --mount --pid --user` namespace wrapper as fallback for environments without chroot capability
|
||||
- [ ] Defense-in-depth layering — chroot + path validation both active; neither alone is sufficient
|
||||
- [ ] Chroot cleanup — integrate with `SessionGCService` / workspace deletion
|
||||
- [ ] AppArmor/SELinux profiles (v2) — restrict gateway process file access patterns for multi-tenant hardening
|
||||
|
||||
---
|
||||
|
||||
## Security Constraints
|
||||
|
||||
- What lives **inside** the chroot (agent-accessible): workspace files, git repo, language runtimes
|
||||
- What lives **outside** the chroot (gateway-only, never agent-accessible): Valkey connection, PG connection, other users' workspaces, gateway config, OTEL endpoint, credentials
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspaces (P8-015) — chroot is rooted at workspace directory; workspace must exist first
|
||||
- Tool hardening (P8-016) — path validation stays active as defense-in-depth alongside chroot
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Chroot Agent Sandboxing" section
|
||||
- Current tool implementations: `apps/gateway/src/agent/tools/`
|
||||
53
docs/plans/gatekeeper-service.md
Normal file
53
docs/plans/gatekeeper-service.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Gatekeeper Service — PR Review, Quality Gates & Merge Authority
|
||||
|
||||
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Phase 7 Workspaces).
|
||||
> Implement after Workspaces (P8-015) is complete and the workspace/git infrastructure is operational.
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Packages:** `apps/gateway`, `packages/types`, `packages/agent`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Project agents create PRs but cannot review or merge their own work. A separate, isolated agent service with read-only code access and quality gate enforcement is needed to act as the authoritative merge authority.
|
||||
|
||||
The Gatekeeper existed in the old Mosaic codebase and must be ported/redesigned for mosaic-mono-v1.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Constraints
|
||||
|
||||
- **Isolated trust boundary** — project agents cannot invoke Gatekeeper directly; it listens for PR events from the git provider
|
||||
- **`isSystem: true`** — system agent, not editable by users
|
||||
- **Read-only code access** — reads diffs and runs checks; cannot commit or push
|
||||
- **Quality gates required before merge** — lint, typecheck, test results must pass
|
||||
- **Cannot self-approve** — the agent that authored the PR cannot be the Gatekeeper for that PR
|
||||
|
||||
---
|
||||
|
||||
## Scope (To Be Designed)
|
||||
|
||||
- [ ] Gatekeeper agent bootstrap — system agent config, tool set, prompt engineering
|
||||
- [ ] PR event listener — Gitea/GitHub webhook integration (PR opened/updated/ready)
|
||||
- [ ] Quality gate runner — trigger CI checks, poll for results, enforce pass criteria
|
||||
- [ ] Review generation — LLM-driven code review comment generation
|
||||
- [ ] Merge execution — approve + merge when gates pass; reject with comments when they fail
|
||||
- [ ] Configurable strictness — per-project required checks, review depth
|
||||
- [ ] Trust boundary enforcement — gateway rejects Gatekeeper tool calls that exceed read-only scope
|
||||
- [ ] Audit trail — OTEL spans for all Gatekeeper decisions (approve/reject/merge)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspaces (P8-015) — Gatekeeper needs project workspace layout to locate code
|
||||
- Git provider API tools — PR creation/review/merge API (Gitea/GitHub/GitLab)
|
||||
- CI/CD tool integration — Woodpecker pipeline status polling
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Gatekeeper Service" section
|
||||
- Workspace RBAC and agent trust model: same document → "RBAC & Filesystem Security"
|
||||
60
docs/plans/task-queue-unification.md
Normal file
60
docs/plans/task-queue-unification.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Task Queue Unification — @mosaic/queue as Unified Orchestration Layer
|
||||
|
||||
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Task Queue & Orchestration section).
|
||||
> Implement after Workspaces (P8-015) is complete. Requires workspace file structure to be in place.
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Packages:** `packages/queue`, `packages/coord`, `packages/db`, `apps/gateway`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Two disconnected task systems exist:
|
||||
|
||||
1. **`@mosaic/coord`** — file-based missions (`mission.json`, `TASKS.md`), file locks, subprocess spawning. Single-machine orchestrator pattern.
|
||||
2. **PG tables** (`tasks`, `mission_tasks`, `missions`) — DB-backed CRUD, REST API, Brain repos.
|
||||
|
||||
An agent using `coord_mission_status` gets file data. The dashboard shows DB data. They are never in sync.
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
|
||||
`@mosaic/queue` becomes the unified task orchestration service bridging PG, workspace files, and Valkey:
|
||||
|
||||
- DB is source of truth for structured state (status, assignees, timestamps)
|
||||
- Workspace files (`TASKS.md`, PRDs) are working copies for agent interaction
|
||||
- Valkey handles real-time assignment queues and agent claim locks
|
||||
- Flatfile fallback for no-DB single-machine deployments (preserves `@mosaic/coord` pattern)
|
||||
|
||||
---
|
||||
|
||||
## Scope (To Be Designed)
|
||||
|
||||
- [ ] `@mosaic/queue` refactor — elevate from ioredis primitive to task orchestration service
|
||||
- [ ] DB ↔ file sync layer — writes to PG propagate to `TASKS.md`; file edits by agents sync back
|
||||
- [ ] Task assignment queue — Valkey-backed RPUSH/BLPOP for agent task claiming
|
||||
- [ ] Agent claim locks — `mosaic:queue:project:{id}:lock:{taskId}` with TTL
|
||||
- [ ] `@mosaic/coord` consolidation — file-based ops ported into queue service; `@mosaic/coord` becomes thin adapter or deprecated
|
||||
- [ ] Flatfile fallback — queue service writes JSON manifests when PG unavailable
|
||||
- [ ] Status pub/sub — real-time task status updates via Valkey pub/sub
|
||||
- [ ] Dependency resolution — block task assignment until dependencies are met
|
||||
- [ ] Orchestrator monitor — gateway process watches task queue, assigns next based on dependency graph
|
||||
- [ ] API surface — queue service exposes typed interface used by agents, gateway, and CLI
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspaces (P8-015) — file sync targets the workspace directory structure
|
||||
- Teams architecture (P8-007) — project ownership determines queue namespacing
|
||||
- DB schema stable — task/mission tables must not change mid-unification
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Task Queue & Orchestration" section
|
||||
- Current `@mosaic/coord` implementation: `packages/coord/src/`
|
||||
- Current `@mosaic/queue` implementation: `packages/queue/src/`
|
||||
@@ -222,3 +222,47 @@ Issues closed: #52, #55, #57, #58, #120-#134
|
||||
- Infrastructure: coord DB migration, agent sandbox hardening
|
||||
- Quality: E2E Playwright suite (~35 tests), comprehensive docs (user/admin/dev/deployment)
|
||||
- Fixes: TUI state updater, agent session sandboxing
|
||||
|
||||
### Session 13 — CLI Command Architecture (P8-005, P8-006)
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | --------- | -------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| 13 | 2026-03-15 | Phase 8 | P8-005, P8-006 | CLI command architecture implemented. DB schema, brain repo, gateway endpoints, CLI commands. PR #158 merged. |
|
||||
|
||||
**Changes delivered:**
|
||||
|
||||
- DB: Extended agents table (projectId, ownerId, systemPrompt, allowedTools, skills, isSystem). Added agentId to conversations.
|
||||
- Brain: New agents repository with findAccessible (owner's + system agents).
|
||||
- Gateway: /api/agents CRUD, consolidated /api/missions with user-scoped CRUD + /tasks sub-routes, coord slimmed to file-based only, agentConfigId wired into session creation.
|
||||
- CLI: `mosaic agent` (--list, --new, --show, --update, --delete), `mosaic mission` (--list, --init, --plan, --update, task subcommand), `mosaic prdy` (gateway-aware), shared with-auth + select-dialog utilities.
|
||||
- TUI: --agent and --project flags, agent name display in top bar, agentId in socket payload.
|
||||
- Types: agentId added to ChatMessagePayload.
|
||||
- Tests: 23/23 gateway tests pass (updated ownership test for user-scoped missions).
|
||||
|
||||
### Session 14 — Platform Architecture Plan Augmentation + Task Breakdown
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | --------- | ---------- | ------------------------------------------------------------- |
|
||||
| 14 | 2026-03-15 | Phase 8 | P8-018 | Augmented plan, created 13 issues, created Phase 8 milestone. |
|
||||
|
||||
**Decisions made:**
|
||||
|
||||
- This plan is Phase 7 feature extension work, not Phase 8 beta scope. P8-001–P8-004 (SSO, LLM, perf, release gate) are deferred to far future.
|
||||
- `/provider` OAuth in TUI: URL-to-clipboard + Valkey poll token pattern (same as Pi agent)
|
||||
- Add `mutable` column to preferences now (P8-007 DB migration)
|
||||
- Teams architecture: `teams` + `team_members` tables, `teamId`/`ownerType` on projects. Workspace path branches on owner type: `users/<uid>/` vs `teams/<tid>/`.
|
||||
- Phase dependency chain decided: Wave 1 (DB+Types) → Wave 2 (TUI+toolhardening) → Wave 3 (gateway registry, gating) → Wave 4 (prefs+commands) → Wave 5 (reload+GC) → Wave 6 (workspaces) → Wave 7 (autocomplete) → Wave 8 (verify).
|
||||
|
||||
**Plan augmentations added:**
|
||||
|
||||
- Teams Architecture section (DB schema, workspace paths, RBAC)
|
||||
- REST Route Specifications table
|
||||
- `/provider` OAuth flow (URL+clipboard+polling)
|
||||
- Preferences `mutable` migration spec
|
||||
- Test Strategy (per-task test files + key test cases)
|
||||
- Phase Execution Order (dependency graph + wave plan)
|
||||
|
||||
**Issues created:** #160–#172 (Gitea milestone ms-165)
|
||||
**P8-018 closed:** Spin-off stubs created (gatekeeper-service.md, task-queue-unification.md, chroot-sandboxing.md)
|
||||
|
||||
**Next:** Begin execution at Wave 1 — P8-007 (DB migrations) + P8-008 (Types) in parallel.
|
||||
|
||||
40
docs/scratchpads/p8-009-tui-slash-commands.md
Normal file
40
docs/scratchpads/p8-009-tui-slash-commands.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# P8-009: TUI Phase 1 — Slash Command Parsing
|
||||
|
||||
## Task Reference
|
||||
|
||||
- Issue: #162
|
||||
- Branch: feat/p8-009-tui-slash-commands
|
||||
|
||||
## Scope
|
||||
|
||||
- New files: parse.ts, registry.ts, local/help.ts, local/status.ts, commands/index.ts
|
||||
- Modified files: use-socket.ts, input-bar.tsx, message-list.tsx, app.tsx
|
||||
|
||||
## Key Observations
|
||||
|
||||
- CommandDef in @mosaic/types does NOT have `category` field — will omit from LOCAL_COMMANDS
|
||||
- CommandDef.args is `CommandArgDef[] | undefined`, not `{ usage: string }` — help.ts args rendering needs adjustment
|
||||
- Message role union currently: 'user' | 'assistant' | 'thinking' | 'tool' — adding 'system'
|
||||
- InputBar currently takes `onSubmit: (value: string) => void` — need to add slash command interception
|
||||
- app.tsx passes `onSubmit={socket.sendMessage}` directly — needs command-aware handler
|
||||
|
||||
## Assumptions
|
||||
|
||||
- ASSUMPTION: `category` field not in CommandDef type — will skip category grouping in help output, or add it only to registry (not to CommandDef type)
|
||||
- ASSUMPTION: For the `args` field display in help, will use `CommandArgDef.name` and `CommandArgDef.description`
|
||||
- ASSUMPTION: `commands:manifest` event type may not be in ServerToClientEvents — will handle via socket.on with casting if needed
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] Create commands directory structure
|
||||
- [ ] Implement parse.ts
|
||||
- [ ] Implement registry.ts
|
||||
- [ ] Implement local/help.ts
|
||||
- [ ] Implement local/status.ts
|
||||
- [ ] Implement commands/index.ts
|
||||
- [ ] Modify use-socket.ts
|
||||
- [ ] Modify input-bar.tsx
|
||||
- [ ] Modify message-list.tsx
|
||||
- [ ] Modify app.tsx
|
||||
- [ ] Run quality gates
|
||||
- [ ] Commit + Push + PR + CI
|
||||
72
docs/scratchpads/p8-010-command-registry.md
Normal file
72
docs/scratchpads/p8-010-command-registry.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# P8-010 Scratchpad — Gateway Phase 2: CommandRegistryService + CommandExecutorService
|
||||
|
||||
## Objective
|
||||
|
||||
Implement gateway-side command registry system:
|
||||
|
||||
- `CommandRegistryService` — owns canonical command manifest, broadcasts on connect
|
||||
- `CommandExecutorService` — routes `command:execute` socket events
|
||||
- `CommandsModule` — NestJS wiring
|
||||
- Wire into `ChatGateway` and `AppModule`
|
||||
- Register core commands
|
||||
- Tests for CommandRegistryService
|
||||
|
||||
## Key Findings from Codebase
|
||||
|
||||
### CommandDef shape (from packages/types/src/commands/index.ts)
|
||||
|
||||
- `scope: 'core' | 'agent' | 'skill' | 'plugin' | 'admin'` (NOT `category`)
|
||||
- `args?: CommandArgDef[]` — array of arg defs, each with `name`, `type`, `optional`, `values?`, `description?`
|
||||
- No `aliases` required (it's listed but optional-ish... wait, it IS in the interface)
|
||||
- `aliases: string[]` — IS present
|
||||
|
||||
### SlashCommandResultPayload requires `conversationId`
|
||||
|
||||
- The task spec shows `{ command, success, error }` without `conversationId` but actual type requires it
|
||||
- Must include `conversationId` in all return values
|
||||
|
||||
### CommandManifest has `skills: SkillCommandDef[]`
|
||||
|
||||
- Must include `skills` array in manifest
|
||||
|
||||
### userId extraction in ChatGateway
|
||||
|
||||
- `client.data.user` holds the user object (set in `handleConnection`)
|
||||
- `client.data.user.id` or similar for userId
|
||||
|
||||
### AgentModule not imported in ChatModule
|
||||
|
||||
- ChatGateway imports AgentService via DI
|
||||
- ChatModule doesn't declare imports — AgentModule must be global or imported
|
||||
|
||||
### Worktree branch
|
||||
|
||||
- Branch: `feat/p8-010-command-registry`
|
||||
- Working in: `/home/jwoltje/src/mosaic-mono-v1/.claude/worktrees/agent-ac85b3b2`
|
||||
|
||||
## Plan
|
||||
|
||||
1. Create `apps/gateway/src/commands/command-registry.service.ts`
|
||||
2. Create `apps/gateway/src/commands/command-executor.service.ts`
|
||||
3. Create `apps/gateway/src/commands/commands.module.ts`
|
||||
4. Modify `apps/gateway/src/app.module.ts` — add CommandsModule
|
||||
5. Modify `apps/gateway/src/chat/chat.module.ts` — import CommandsModule
|
||||
6. Modify `apps/gateway/src/chat/chat.gateway.ts` — inject services, add handler, emit manifest
|
||||
7. Create `apps/gateway/src/commands/command-registry.service.spec.ts`
|
||||
|
||||
## Progress
|
||||
|
||||
- [ ] Create CommandRegistryService
|
||||
- [ ] Create CommandExecutorService
|
||||
- [ ] Create CommandsModule
|
||||
- [ ] Update AppModule
|
||||
- [ ] Update ChatModule
|
||||
- [ ] Update ChatGateway
|
||||
- [ ] Write tests
|
||||
- [ ] Run quality gates
|
||||
- [ ] Commit + push + PR
|
||||
|
||||
## Risks
|
||||
|
||||
- CommandDef `args` shape mismatch from task spec — must use actual type
|
||||
- `SlashCommandResultPayload.conversationId` is required — handle missing conversationId
|
||||
55
docs/scratchpads/p8-016-tool-hardening.md
Normal file
55
docs/scratchpads/p8-016-tool-hardening.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# P8-016: Security — Tool Path Hardening + Sandbox Escape Prevention
|
||||
|
||||
## Status: in-progress
|
||||
|
||||
## Branch: feat/p8-016-tool-hardening
|
||||
|
||||
## Issue: #169
|
||||
|
||||
## Scope
|
||||
|
||||
Harden file, git, and shell tool factories so no path operation escapes `sandboxDir`.
|
||||
|
||||
## Files to Create
|
||||
|
||||
- `apps/gateway/src/agent/tools/path-guard.ts` (new)
|
||||
- `apps/gateway/src/agent/tools/path-guard.test.ts` (new)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
- `apps/gateway/src/agent/tools/file-tools.ts`
|
||||
- `apps/gateway/src/agent/tools/git-tools.ts`
|
||||
- `apps/gateway/src/agent/tools/shell-tools.ts`
|
||||
|
||||
## Analysis
|
||||
|
||||
### file-tools.ts
|
||||
|
||||
- Has existing `resolveSafe()` function but uses weak containment check (relative path)
|
||||
- Replace with `guardPath` (for reads/lists on existing paths) and `guardPathUnsafe` (for writes)
|
||||
- Error pattern: return `{ content: [{ type: 'text', text: 'Error: ...' }], details: undefined }`
|
||||
|
||||
### git-tools.ts
|
||||
|
||||
- Has `clampCwd()` that silently falls back to sandbox root on escape attempt
|
||||
- Replace with strict `guardPath` that throws SandboxEscapeError, caught and returned as error
|
||||
- Also need to guard the `path` parameter in `git_diff`
|
||||
|
||||
### shell-tools.ts
|
||||
|
||||
- Has `clampCwd()` same silent-fallback approach
|
||||
- Replace with strict `guardPath` that throws SandboxEscapeError
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- `guardPath`: uses `realpathSync.native` to resolve symlinks, requires path to exist
|
||||
- `guardPathUnsafe`: lexical only (`path.resolve`), for paths that may not exist yet
|
||||
- Both throw `SandboxEscapeError` on escape attempt
|
||||
- Callers catch and return error result
|
||||
|
||||
## Verification
|
||||
|
||||
- pnpm typecheck
|
||||
- pnpm lint
|
||||
- pnpm format:check
|
||||
- pnpm test
|
||||
58
packages/brain/src/agents.ts
Normal file
58
packages/brain/src/agents.ts
Normal 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>;
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -21,15 +21,17 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.9.0",
|
||||
"@mosaic/mosaic": "workspace:^",
|
||||
"@mosaic/prdy": "workspace:^",
|
||||
"@mosaic/quality-rails": "workspace:^",
|
||||
"@mosaic/types": "workspace:^",
|
||||
"commander": "^13.0.0",
|
||||
"ink": "^5.0.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"react": "^18.3.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"commander": "^13.0.0"
|
||||
"socket.io-client": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
241
packages/cli/src/commands/agent.ts
Normal file
241
packages/cli/src/commands/agent.ts
Normal 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.`);
|
||||
}
|
||||
385
packages/cli/src/commands/mission.ts
Normal file
385
packages/cli/src/commands/mission.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
55
packages/cli/src/commands/prdy.ts
Normal file
55
packages/cli/src/commands/prdy.ts
Normal 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;
|
||||
}
|
||||
58
packages/cli/src/commands/select-dialog.ts
Normal file
58
packages/cli/src/commands/select-dialog.ts
Normal 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];
|
||||
}
|
||||
29
packages/cli/src/commands/with-auth.ts
Normal file
29
packages/cli/src/commands/with-auth.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,392 +1,319 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, Text, useInput, useApp } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { fetchAvailableModels, type ModelInfo } from './gateway-api.js';
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Box, useApp, useInput } from 'ink';
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
import { TopBar } from './components/top-bar.js';
|
||||
import { BottomBar } from './components/bottom-bar.js';
|
||||
import { MessageList } from './components/message-list.js';
|
||||
import { InputBar } from './components/input-bar.js';
|
||||
import { Sidebar } from './components/sidebar.js';
|
||||
import { SearchBar } from './components/search-bar.js';
|
||||
import { useSocket } from './hooks/use-socket.js';
|
||||
import { useGitInfo } from './hooks/use-git-info.js';
|
||||
import { useViewport } from './hooks/use-viewport.js';
|
||||
import { useAppMode } from './hooks/use-app-mode.js';
|
||||
import { useConversations } from './hooks/use-conversations.js';
|
||||
import { useSearch } from './hooks/use-search.js';
|
||||
import { executeHelp, executeStatus } from './commands/index.js';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface TuiAppProps {
|
||||
export interface TuiAppProps {
|
||||
gatewayUrl: string;
|
||||
conversationId?: string;
|
||||
sessionCookie?: string;
|
||||
initialModel?: string;
|
||||
initialProvider?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a slash command from user input.
|
||||
* Returns null if the input is not a slash command.
|
||||
*/
|
||||
function parseSlashCommand(value: string): { command: string; args: string[] } | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith('/')) return null;
|
||||
const parts = trimmed.slice(1).split(/\s+/);
|
||||
const command = parts[0]?.toLowerCase() ?? '';
|
||||
const args = parts.slice(1);
|
||||
return { command, args };
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function TuiApp({
|
||||
gatewayUrl,
|
||||
conversationId: initialConversationId,
|
||||
conversationId,
|
||||
sessionCookie,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
agentName,
|
||||
projectId: _projectId,
|
||||
}: TuiAppProps) {
|
||||
const { exit } = useApp();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
||||
const gitInfo = useGitInfo();
|
||||
const appMode = useAppMode();
|
||||
|
||||
// Model/provider state
|
||||
const [currentModel, setCurrentModel] = useState<string | undefined>(initialModel);
|
||||
const [currentProvider, setCurrentProvider] = useState<string | undefined>(initialProvider);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const socket = useSocket({
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
initialConversationId: conversationId,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
});
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const currentStreamTextRef = useRef('');
|
||||
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||
|
||||
// Fetch available models on mount
|
||||
const viewport = useViewport({ totalItems: socket.messages.length });
|
||||
|
||||
const search = useSearch(socket.messages);
|
||||
|
||||
// Scroll to current match when it changes
|
||||
const currentMatch = search.matches[search.currentMatchIndex];
|
||||
useEffect(() => {
|
||||
fetchAvailableModels(gatewayUrl, sessionCookie)
|
||||
.then((models) => {
|
||||
setAvailableModels(models);
|
||||
// If no model/provider specified and models are available, show the default
|
||||
if (!initialModel && !initialProvider && models.length > 0) {
|
||||
const first = models[0];
|
||||
if (first) {
|
||||
setCurrentModel(first.id);
|
||||
setCurrentProvider(first.provider);
|
||||
}
|
||||
if (currentMatch && appMode.mode === 'search') {
|
||||
viewport.scrollTo(currentMatch.messageIndex);
|
||||
}
|
||||
}, [currentMatch, appMode.mode, viewport]);
|
||||
|
||||
// Compute highlighted message indices for MessageList
|
||||
const highlightedMessageIndices = useMemo(() => {
|
||||
if (search.matches.length === 0) return undefined;
|
||||
return new Set(search.matches.map((m) => m.messageIndex));
|
||||
}, [search.matches]);
|
||||
|
||||
const currentHighlightIndex = currentMatch?.messageIndex;
|
||||
|
||||
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
||||
|
||||
const handleLocalCommand = useCallback(
|
||||
(parsed: ParsedCommand) => {
|
||||
switch (parsed.command) {
|
||||
case 'help':
|
||||
case 'h': {
|
||||
const result = executeHelp(parsed);
|
||||
socket.addSystemMessage(result);
|
||||
break;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-fatal: TUI works without model list
|
||||
});
|
||||
}, [gatewayUrl, sessionCookie, initialModel, initialProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io(`${gatewayUrl}/chat`, {
|
||||
transports: ['websocket'],
|
||||
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => setConnected(true));
|
||||
socket.on('disconnect', () => {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
setCurrentStreamText('');
|
||||
});
|
||||
socket.on('connect_error', (err: Error) => {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: `Connection failed: ${err.message}. Check that the gateway is running at ${gatewayUrl}.`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
socket.on('message:ack', (data: { conversationId: string }) => {
|
||||
setConversationId(data.conversationId);
|
||||
});
|
||||
|
||||
socket.on('agent:start', () => {
|
||||
setIsStreaming(true);
|
||||
currentStreamTextRef.current = '';
|
||||
setCurrentStreamText('');
|
||||
});
|
||||
|
||||
socket.on('agent:text', (data: { text: string }) => {
|
||||
currentStreamTextRef.current += data.text;
|
||||
setCurrentStreamText(currentStreamTextRef.current);
|
||||
});
|
||||
|
||||
socket.on('agent:end', () => {
|
||||
const finalText = currentStreamTextRef.current;
|
||||
currentStreamTextRef.current = '';
|
||||
setCurrentStreamText('');
|
||||
if (finalText) {
|
||||
setMessages((msgs) => [...msgs, { role: 'assistant', content: finalText }]);
|
||||
}
|
||||
setIsStreaming(false);
|
||||
});
|
||||
|
||||
socket.on('error', (data: { error: string }) => {
|
||||
setMessages((msgs) => [...msgs, { role: 'assistant', content: `Error: ${data.error}` }]);
|
||||
setIsStreaming(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [gatewayUrl]);
|
||||
|
||||
/**
|
||||
* Handle /model and /provider slash commands.
|
||||
* Returns true if the input was a handled slash command (should not be sent to gateway).
|
||||
*/
|
||||
const handleSlashCommand = useCallback(
|
||||
(value: string): boolean => {
|
||||
const parsed = parseSlashCommand(value);
|
||||
if (!parsed) return false;
|
||||
|
||||
const { command, args } = parsed;
|
||||
|
||||
if (command === 'model') {
|
||||
if (args.length === 0) {
|
||||
// List available models
|
||||
if (availableModels.length === 0) {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'No models available (could not reach gateway). Use /model <modelId> to set one manually.',
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const lines = availableModels.map(
|
||||
(m) =>
|
||||
` ${m.provider}/${m.id}${m.id === currentModel && m.provider === currentProvider ? ' (active)' : ''}`,
|
||||
);
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'system',
|
||||
content: `Available models:\n${lines.join('\n')}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Switch model: /model <modelId> or /model <provider>/<modelId>
|
||||
const arg = args[0]!;
|
||||
const slashIdx = arg.indexOf('/');
|
||||
let newProvider: string | undefined;
|
||||
let newModelId: string;
|
||||
|
||||
if (slashIdx !== -1) {
|
||||
newProvider = arg.slice(0, slashIdx);
|
||||
newModelId = arg.slice(slashIdx + 1);
|
||||
} else {
|
||||
newModelId = arg;
|
||||
// Try to find provider from available models list
|
||||
const match = availableModels.find((m) => m.id === newModelId);
|
||||
newProvider = match?.provider ?? currentProvider;
|
||||
}
|
||||
|
||||
setCurrentModel(newModelId);
|
||||
if (newProvider) setCurrentProvider(newProvider);
|
||||
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'system',
|
||||
content: `Switched to model: ${newProvider ? `${newProvider}/` : ''}${newModelId}. Takes effect on next message.`,
|
||||
},
|
||||
]);
|
||||
case 'status':
|
||||
case 's': {
|
||||
const result = executeStatus(parsed, {
|
||||
connected: socket.connected,
|
||||
model: socket.modelName,
|
||||
provider: socket.providerName,
|
||||
sessionId: socket.conversationId ?? null,
|
||||
tokenCount: socket.tokenUsage.total,
|
||||
});
|
||||
socket.addSystemMessage(result);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (command === 'provider') {
|
||||
if (args.length === 0) {
|
||||
// List providers from available models
|
||||
const providers = [...new Set(availableModels.map((m) => m.provider))];
|
||||
if (providers.length === 0) {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'No providers available (could not reach gateway). Use /provider <name> to set one manually.',
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const lines = providers.map((p) => ` ${p}${p === currentProvider ? ' (active)' : ''}`);
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'system',
|
||||
content: `Available providers:\n${lines.join('\n')}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
const newProvider = args[0]!;
|
||||
setCurrentProvider(newProvider);
|
||||
// If switching provider, auto-select first model for that provider
|
||||
const providerModels = availableModels.filter((m) => m.provider === newProvider);
|
||||
if (providerModels.length > 0 && providerModels[0]) {
|
||||
setCurrentModel(providerModels[0].id);
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'system',
|
||||
content: `Switched to provider: ${newProvider} (model: ${providerModels[0]!.id}). Takes effect on next message.`,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'system',
|
||||
content: `Switched to provider: ${newProvider}. Takes effect on next message.`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
case 'clear':
|
||||
socket.clearMessages();
|
||||
break;
|
||||
case 'stop':
|
||||
// Currently no stop mechanism exposed — show feedback
|
||||
socket.addSystemMessage('Stop is not available for the current session.');
|
||||
break;
|
||||
case 'cost': {
|
||||
const u = socket.tokenUsage;
|
||||
socket.addSystemMessage(
|
||||
`Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
socket.addSystemMessage(`Local command not implemented: /${parsed.command}`);
|
||||
}
|
||||
|
||||
if (command === 'help') {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
'Available commands:',
|
||||
' /model — list available models',
|
||||
' /model <id> — switch model (e.g. /model gpt-4o)',
|
||||
' /model <p>/<id> — switch model with provider (e.g. /model ollama/llama3.2)',
|
||||
' /provider — list available providers',
|
||||
' /provider <name> — switch provider (e.g. /provider ollama)',
|
||||
' /help — show this help',
|
||||
].join('\n'),
|
||||
},
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unknown slash command — let the user know
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
role: 'system',
|
||||
content: `Unknown command: /${command}. Type /help for available commands.`,
|
||||
},
|
||||
]);
|
||||
return true;
|
||||
},
|
||||
[availableModels, currentModel, currentProvider],
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim() || isStreaming) return;
|
||||
|
||||
setInput('');
|
||||
|
||||
// Handle slash commands first
|
||||
if (handleSlashCommand(value)) return;
|
||||
|
||||
if (!socketRef.current?.connected) {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'assistant', content: 'Not connected to gateway. Message not sent.' },
|
||||
]);
|
||||
const handleGatewayCommand = useCallback(
|
||||
(parsed: ParsedCommand) => {
|
||||
if (!socket.socketRef.current?.connected || !socket.conversationId) {
|
||||
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((msgs) => [...msgs, { role: 'user', content: value }]);
|
||||
|
||||
socketRef.current.emit('message', {
|
||||
conversationId,
|
||||
content: value,
|
||||
provider: currentProvider,
|
||||
modelId: currentModel,
|
||||
socket.socketRef.current.emit('command:execute', {
|
||||
conversationId: socket.conversationId,
|
||||
command: parsed.command,
|
||||
args: parsed.args ?? undefined,
|
||||
});
|
||||
},
|
||||
[conversationId, isStreaming, currentModel, currentProvider, handleSlashCommand],
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleSwitchConversation = useCallback(
|
||||
(id: string) => {
|
||||
socket.switchConversation(id);
|
||||
appMode.setMode('chat');
|
||||
},
|
||||
[socket, appMode],
|
||||
);
|
||||
|
||||
const handleDeleteConversation = useCallback(
|
||||
(id: string) => {
|
||||
void conversations
|
||||
.deleteConversation(id)
|
||||
.then((ok) => {
|
||||
if (ok && id === socket.conversationId) {
|
||||
socket.clearMessages();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[conversations, socket],
|
||||
);
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.ctrl && ch === 'c') {
|
||||
exit();
|
||||
}
|
||||
// Ctrl+L: toggle sidebar (refresh on open)
|
||||
if (key.ctrl && ch === 'l') {
|
||||
const willOpen = !appMode.sidebarOpen;
|
||||
appMode.toggleSidebar();
|
||||
if (willOpen) {
|
||||
void conversations.refresh();
|
||||
}
|
||||
}
|
||||
// Ctrl+N: create new conversation and switch to it
|
||||
if (key.ctrl && ch === 'n') {
|
||||
void conversations
|
||||
.createConversation()
|
||||
.then((conv) => {
|
||||
if (conv) {
|
||||
socket.switchConversation(conv.id);
|
||||
appMode.setMode('chat');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
// Ctrl+K: toggle search mode
|
||||
if (key.ctrl && ch === 'k') {
|
||||
if (appMode.mode === 'search') {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
} else {
|
||||
appMode.setMode('search');
|
||||
}
|
||||
}
|
||||
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||
if (appMode.mode === 'chat') {
|
||||
if (key.pageUp) {
|
||||
viewport.scrollBy(-viewport.viewportSize);
|
||||
}
|
||||
if (key.pageDown) {
|
||||
viewport.scrollBy(viewport.viewportSize);
|
||||
}
|
||||
}
|
||||
// Ctrl+T: cycle thinking level
|
||||
if (key.ctrl && ch === 't') {
|
||||
const levels = socket.availableThinkingLevels;
|
||||
if (levels.length > 0) {
|
||||
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
||||
const nextIdx = (currentIdx + 1) % levels.length;
|
||||
const next = levels[nextIdx];
|
||||
if (next) {
|
||||
socket.setThinkingLevel(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
||||
if (key.escape) {
|
||||
if (appMode.mode === 'search') {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
} else if (appMode.mode === 'sidebar') {
|
||||
appMode.setMode('chat');
|
||||
} else if (appMode.mode === 'chat') {
|
||||
viewport.scrollToBottom();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const modelLabel = currentModel
|
||||
? currentProvider
|
||||
? `${currentProvider}/${currentModel}`
|
||||
: currentModel
|
||||
: null;
|
||||
const inputPlaceholder =
|
||||
appMode.mode === 'sidebar'
|
||||
? 'focus is on sidebar… press Esc to return'
|
||||
: appMode.mode === 'search'
|
||||
? 'search mode… press Esc to return'
|
||||
: undefined;
|
||||
|
||||
const isSearchMode = appMode.mode === 'search';
|
||||
|
||||
const messageArea = (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<MessageList
|
||||
messages={socket.messages}
|
||||
isStreaming={socket.isStreaming}
|
||||
currentStreamText={socket.currentStreamText}
|
||||
currentThinkingText={socket.currentThinkingText}
|
||||
activeToolCalls={socket.activeToolCalls}
|
||||
scrollOffset={viewport.scrollOffset}
|
||||
viewportSize={viewport.viewportSize}
|
||||
isScrolledUp={viewport.isScrolledUp}
|
||||
highlightedMessageIndices={highlightedMessageIndices}
|
||||
currentHighlightIndex={currentHighlightIndex}
|
||||
/>
|
||||
|
||||
{isSearchMode && (
|
||||
<SearchBar
|
||||
query={search.query}
|
||||
onQueryChange={search.setQuery}
|
||||
totalMatches={search.totalMatches}
|
||||
currentMatch={search.currentMatchIndex}
|
||||
onNext={search.nextMatch}
|
||||
onPrev={search.prevMatch}
|
||||
onClose={() => {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
}}
|
||||
focused={isSearchMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputBar
|
||||
onSubmit={socket.sendMessage}
|
||||
onSystemMessage={socket.addSystemMessage}
|
||||
onLocalCommand={handleLocalCommand}
|
||||
onGatewayCommand={handleGatewayCommand}
|
||||
isStreaming={socket.isStreaming}
|
||||
connected={socket.connected}
|
||||
placeholder={inputPlaceholder}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color="blue">
|
||||
Mosaic
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>{connected ? `connected` : 'connecting...'}</Text>
|
||||
{conversationId && <Text dimColor> | {conversationId.slice(0, 8)}</Text>}
|
||||
{modelLabel && (
|
||||
<>
|
||||
<Text dimColor> | </Text>
|
||||
<Text color="yellow">{modelLabel}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column" height="100%">
|
||||
<Box marginTop={1} />
|
||||
<TopBar
|
||||
gatewayUrl={gatewayUrl}
|
||||
version="0.0.0"
|
||||
modelName={socket.modelName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
contextWindow={socket.tokenUsage.contextWindow}
|
||||
agentName={agentName ?? 'default'}
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
/>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{messages.map((msg, i) => (
|
||||
<Box key={i} marginBottom={1}>
|
||||
{msg.role === 'system' ? (
|
||||
<Text dimColor italic>
|
||||
{msg.content}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text bold color={msg.role === 'user' ? 'green' : 'cyan'}>
|
||||
{msg.role === 'user' ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text wrap="wrap">{msg.content}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{appMode.sidebarOpen ? (
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<Sidebar
|
||||
conversations={conversations.conversations}
|
||||
activeConversationId={socket.conversationId}
|
||||
selectedIndex={sidebarSelectedIndex}
|
||||
onSelectIndex={setSidebarSelectedIndex}
|
||||
onSwitchConversation={handleSwitchConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
loading={conversations.loading}
|
||||
focused={appMode.mode === 'sidebar'}
|
||||
width={30}
|
||||
/>
|
||||
{messageArea}
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexGrow={1}>{messageArea}</Box>
|
||||
)}
|
||||
|
||||
{isStreaming && currentStreamText && (
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color="cyan">
|
||||
{' '}
|
||||
</Text>
|
||||
<Text wrap="wrap">{currentStreamText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isStreaming && !currentStreamText && (
|
||||
<Box>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text dimColor> thinking...</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text bold color="green">
|
||||
{'> '}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={isStreaming ? 'waiting...' : 'type a message or /help'}
|
||||
/>
|
||||
</Box>
|
||||
<BottomBar
|
||||
gitInfo={gitInfo}
|
||||
tokenUsage={socket.tokenUsage}
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
modelName={socket.modelName}
|
||||
providerName={socket.providerName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
conversationId={socket.conversationId}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
5
packages/cli/src/tui/commands/index.ts
Normal file
5
packages/cli/src/tui/commands/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { parseSlashCommand } from './parse.js';
|
||||
export { commandRegistry, CommandRegistry } from './registry.js';
|
||||
export { executeHelp } from './local/help.js';
|
||||
export { executeStatus } from './local/status.js';
|
||||
export type { StatusContext } from './local/status.js';
|
||||
19
packages/cli/src/tui/commands/local/help.ts
Normal file
19
packages/cli/src/tui/commands/local/help.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
import { commandRegistry } from '../registry.js';
|
||||
|
||||
export function executeHelp(_parsed: ParsedCommand): string {
|
||||
const commands = commandRegistry.getAll();
|
||||
const lines = ['Available commands:', ''];
|
||||
|
||||
for (const cmd of commands) {
|
||||
const aliases =
|
||||
cmd.aliases.length > 0 ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : '';
|
||||
const argsStr =
|
||||
cmd.args && cmd.args.length > 0
|
||||
? ' ' + cmd.args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ')
|
||||
: '';
|
||||
lines.push(` /${cmd.name}${argsStr}${aliases} — ${cmd.description}`);
|
||||
}
|
||||
|
||||
return lines.join('\n').trimEnd();
|
||||
}
|
||||
20
packages/cli/src/tui/commands/local/status.ts
Normal file
20
packages/cli/src/tui/commands/local/status.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
|
||||
export interface StatusContext {
|
||||
connected: boolean;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
sessionId: string | null;
|
||||
tokenCount: number;
|
||||
}
|
||||
|
||||
export function executeStatus(_parsed: ParsedCommand, ctx: StatusContext): string {
|
||||
const lines = [
|
||||
`Connection: ${ctx.connected ? 'connected' : 'disconnected'}`,
|
||||
`Model: ${ctx.model ?? 'unknown'}`,
|
||||
`Provider: ${ctx.provider ?? 'unknown'}`,
|
||||
`Session: ${ctx.sessionId ?? 'none'}`,
|
||||
`Tokens (session): ${ctx.tokenCount}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
11
packages/cli/src/tui/commands/parse.ts
Normal file
11
packages/cli/src/tui/commands/parse.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
|
||||
export function parseSlashCommand(input: string): ParsedCommand | null {
|
||||
const match = input.match(/^\/([a-z][a-z0-9:_-]*)\s*(.*)?$/i);
|
||||
if (!match) return null;
|
||||
return {
|
||||
command: match[1]!,
|
||||
args: match[2]?.trim() || null,
|
||||
raw: input,
|
||||
};
|
||||
}
|
||||
98
packages/cli/src/tui/commands/registry.ts
Normal file
98
packages/cli/src/tui/commands/registry.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { CommandDef, CommandManifest } from '@mosaic/types';
|
||||
|
||||
// Local-only commands (work even when gateway is disconnected)
|
||||
const LOCAL_COMMANDS: CommandDef[] = [
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
aliases: ['h'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
description: 'Cancel current streaming response',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'cost',
|
||||
description: 'Show token usage and cost for current session',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
description: 'Show connection and session status',
|
||||
aliases: ['s'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Clear the current conversation display',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
];
|
||||
|
||||
const ALIASES: Record<string, string> = {
|
||||
n: 'new',
|
||||
m: 'model',
|
||||
t: 'thinking',
|
||||
a: 'agent',
|
||||
s: 'status',
|
||||
h: 'help',
|
||||
pref: 'preferences',
|
||||
};
|
||||
|
||||
export class CommandRegistry {
|
||||
private gatewayManifest: CommandManifest | null = null;
|
||||
|
||||
updateManifest(manifest: CommandManifest): void {
|
||||
this.gatewayManifest = manifest;
|
||||
}
|
||||
|
||||
resolveAlias(command: string): string {
|
||||
return ALIASES[command] ?? command;
|
||||
}
|
||||
|
||||
find(command: string): CommandDef | null {
|
||||
const resolved = this.resolveAlias(command);
|
||||
// Search local first, then gateway manifest
|
||||
const local = LOCAL_COMMANDS.find((c) => c.name === resolved || c.aliases.includes(resolved));
|
||||
if (local) return local;
|
||||
if (this.gatewayManifest) {
|
||||
return (
|
||||
this.gatewayManifest.commands.find(
|
||||
(c) => c.name === resolved || c.aliases.includes(resolved),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getAll(): CommandDef[] {
|
||||
const gateway = this.gatewayManifest?.commands ?? [];
|
||||
return [...LOCAL_COMMANDS, ...gateway];
|
||||
}
|
||||
|
||||
getLocalCommands(): CommandDef[] {
|
||||
return LOCAL_COMMANDS;
|
||||
}
|
||||
}
|
||||
|
||||
export const commandRegistry = new CommandRegistry();
|
||||
125
packages/cli/src/tui/components/bottom-bar.tsx
Normal file
125
packages/cli/src/tui/components/bottom-bar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { TokenUsage } from '../hooks/use-socket.js';
|
||||
import type { GitInfo } from '../hooks/use-git-info.js';
|
||||
|
||||
export interface BottomBarProps {
|
||||
gitInfo: GitInfo;
|
||||
tokenUsage: TokenUsage;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
modelName: string | null;
|
||||
providerName: string | null;
|
||||
thinkingLevel: string;
|
||||
conversationId: string | undefined;
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Compact the cwd — replace home with ~ */
|
||||
function compactCwd(cwd: string): string {
|
||||
const home = process.env['HOME'] ?? '';
|
||||
if (home && cwd.startsWith(home)) {
|
||||
return '~' + cwd.slice(home.length);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function BottomBar({
|
||||
gitInfo,
|
||||
tokenUsage,
|
||||
connected,
|
||||
connecting,
|
||||
modelName,
|
||||
providerName,
|
||||
thinkingLevel,
|
||||
conversationId,
|
||||
}: BottomBarProps) {
|
||||
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
|
||||
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||
|
||||
const hasTokens = tokenUsage.total > 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={0} marginTop={0}>
|
||||
{/* Line 0: keybinding hints */}
|
||||
<Box>
|
||||
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
|
||||
</Box>
|
||||
|
||||
{/* Line 1: blank ····· Gateway: Status */}
|
||||
<Box justifyContent="space-between">
|
||||
<Box />
|
||||
<Box>
|
||||
<Text dimColor>Gateway: </Text>
|
||||
<Text color={gatewayColor}>{gatewayStatus}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 2: cwd (branch) ····· Session: id */}
|
||||
<Box justifyContent="space-between">
|
||||
<Box>
|
||||
<Text dimColor>{compactCwd(gitInfo.cwd)}</Text>
|
||||
{gitInfo.branch && <Text dimColor> ({gitInfo.branch})</Text>}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 3: token stats ····· (provider) model */}
|
||||
<Box justifyContent="space-between" minHeight={1}>
|
||||
<Box>
|
||||
{hasTokens ? (
|
||||
<>
|
||||
<Text dimColor>↑{formatTokens(tokenUsage.input)}</Text>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>↓{formatTokens(tokenUsage.output)}</Text>
|
||||
{tokenUsage.cacheRead > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>R{formatTokens(tokenUsage.cacheRead)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.cacheWrite > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>W{formatTokens(tokenUsage.cacheWrite)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.cost > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>${tokenUsage.cost.toFixed(3)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.contextPercent > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>
|
||||
{tokenUsage.contextPercent.toFixed(1)}%/{formatTokens(tokenUsage.contextWindow)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text dimColor>↑0 ↓0 $0.000</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{providerName ? `(${providerName}) ` : ''}
|
||||
{modelName ?? 'awaiting model'}
|
||||
{thinkingLevel !== 'off' ? ` • ${thinkingLevel}` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
87
packages/cli/src/tui/components/input-bar.tsx
Normal file
87
packages/cli/src/tui/components/input-bar.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
|
||||
|
||||
export interface InputBarProps {
|
||||
onSubmit: (value: string) => void;
|
||||
onSystemMessage?: (message: string) => void;
|
||||
onLocalCommand?: (parsed: ParsedCommand) => void;
|
||||
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
||||
isStreaming: boolean;
|
||||
connected: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function InputBar({
|
||||
onSubmit,
|
||||
onSystemMessage,
|
||||
onLocalCommand,
|
||||
onGatewayCommand,
|
||||
isStreaming,
|
||||
connected,
|
||||
placeholder: placeholderOverride,
|
||||
}: InputBarProps) {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim() || isStreaming || !connected) return;
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const parsed = parseSlashCommand(trimmed);
|
||||
if (!parsed) {
|
||||
onSystemMessage?.(`Unknown command format: ${trimmed}`);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
const def = commandRegistry.find(parsed.command);
|
||||
if (!def) {
|
||||
onSystemMessage?.(
|
||||
`Unknown command: /${parsed.command}. Type /help for available commands.`,
|
||||
);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
if (def.execution === 'local') {
|
||||
onLocalCommand?.(parsed);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
// Gateway-executed commands
|
||||
onGatewayCommand?.(parsed);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(value);
|
||||
setInput('');
|
||||
},
|
||||
[onSubmit, onSystemMessage, onLocalCommand, onGatewayCommand, isStreaming, connected],
|
||||
);
|
||||
|
||||
const placeholder =
|
||||
placeholderOverride ??
|
||||
(!connected
|
||||
? 'disconnected — waiting for gateway…'
|
||||
: isStreaming
|
||||
? 'waiting for response…'
|
||||
: 'message mosaic…');
|
||||
|
||||
return (
|
||||
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||
<Text bold color="green">
|
||||
{'❯ '}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
192
packages/cli/src/tui/components/message-list.tsx
Normal file
192
packages/cli/src/tui/components/message-list.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import type { Message, ToolCall } from '../hooks/use-socket.js';
|
||||
|
||||
export interface MessageListProps {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
currentStreamText: string;
|
||||
currentThinkingText: string;
|
||||
activeToolCalls: ToolCall[];
|
||||
scrollOffset?: number;
|
||||
viewportSize?: number;
|
||||
isScrolledUp?: boolean;
|
||||
highlightedMessageIndices?: Set<number>;
|
||||
currentHighlightIndex?: number;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function SystemMessageBubble({ msg }: { msg: Message }) {
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1} marginLeft={2}>
|
||||
<Text dimColor>{'⚙ '}</Text>
|
||||
<Text dimColor wrap="wrap">
|
||||
{msg.content}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
msg,
|
||||
highlight,
|
||||
}: {
|
||||
msg: Message;
|
||||
highlight?: 'match' | 'current' | undefined;
|
||||
}) {
|
||||
if (msg.role === 'system') {
|
||||
return <SystemMessageBubble msg={msg} />;
|
||||
}
|
||||
|
||||
const isUser = msg.role === 'user';
|
||||
const prefix = isUser ? '❯' : '◆';
|
||||
const color = isUser ? 'green' : 'cyan';
|
||||
|
||||
const borderIndicator =
|
||||
highlight === 'current' ? (
|
||||
<Text color="yellowBright" bold>
|
||||
▌{' '}
|
||||
</Text>
|
||||
) : highlight === 'match' ? (
|
||||
<Text color="yellow">▌ </Text>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{borderIndicator}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={color}>
|
||||
{prefix}{' '}
|
||||
</Text>
|
||||
<Text bold color={color}>
|
||||
{isUser ? 'you' : 'assistant'}
|
||||
</Text>
|
||||
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="wrap">{msg.content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallIndicator({ toolCall }: { toolCall: ToolCall }) {
|
||||
const icon = toolCall.status === 'running' ? null : toolCall.status === 'success' ? '✓' : '✗';
|
||||
const color =
|
||||
toolCall.status === 'running' ? 'yellow' : toolCall.status === 'success' ? 'green' : 'red';
|
||||
|
||||
return (
|
||||
<Box marginLeft={2}>
|
||||
{toolCall.status === 'running' ? (
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={color}>{icon}</Text>
|
||||
)}
|
||||
<Text dimColor> tool: </Text>
|
||||
<Text color={color}>{toolCall.toolName}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
currentStreamText,
|
||||
currentThinkingText,
|
||||
activeToolCalls,
|
||||
scrollOffset,
|
||||
viewportSize,
|
||||
isScrolledUp,
|
||||
highlightedMessageIndices,
|
||||
currentHighlightIndex,
|
||||
}: MessageListProps) {
|
||||
const useSlicing = scrollOffset != null && viewportSize != null;
|
||||
const visibleMessages = useSlicing
|
||||
? messages.slice(scrollOffset, scrollOffset + viewportSize)
|
||||
: messages;
|
||||
const hiddenAbove = useSlicing ? scrollOffset : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||
{isScrolledUp && hiddenAbove > 0 && (
|
||||
<Box justifyContent="center">
|
||||
<Text dimColor>↑ {hiddenAbove} more messages ↑</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<Box justifyContent="center" marginY={1}>
|
||||
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{visibleMessages.map((msg, i) => {
|
||||
const globalIndex = hiddenAbove + i;
|
||||
const highlight =
|
||||
globalIndex === currentHighlightIndex
|
||||
? ('current' as const)
|
||||
: highlightedMessageIndices?.has(globalIndex)
|
||||
? ('match' as const)
|
||||
: undefined;
|
||||
return <MessageBubble key={globalIndex} msg={msg} highlight={highlight} />;
|
||||
})}
|
||||
|
||||
{/* Active thinking */}
|
||||
{isStreaming && currentThinkingText && (
|
||||
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
||||
<Text dimColor italic>
|
||||
💭 {currentThinkingText}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Active tool calls */}
|
||||
{activeToolCalls.length > 0 && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{activeToolCalls.map((tc) => (
|
||||
<ToolCallIndicator key={tc.toolCallId} toolCall={tc} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Streaming response */}
|
||||
{isStreaming && currentStreamText && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
◆{' '}
|
||||
</Text>
|
||||
<Text bold color="cyan">
|
||||
assistant
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="wrap">{currentStreamText}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Waiting spinner */}
|
||||
{isStreaming && !currentStreamText && activeToolCalls.length === 0 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text dimColor> thinking…</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
60
packages/cli/src/tui/components/search-bar.tsx
Normal file
60
packages/cli/src/tui/components/search-bar.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
export interface SearchBarProps {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
totalMatches: number;
|
||||
currentMatch: number;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onClose: () => void;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
query,
|
||||
onQueryChange,
|
||||
totalMatches,
|
||||
currentMatch,
|
||||
onNext,
|
||||
onPrev,
|
||||
onClose,
|
||||
focused,
|
||||
}: SearchBarProps) {
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onPrev();
|
||||
}
|
||||
if (key.downArrow) {
|
||||
onNext();
|
||||
}
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const borderColor = focused ? 'yellow' : 'gray';
|
||||
|
||||
const matchDisplay =
|
||||
query.length >= 2
|
||||
? totalMatches > 0
|
||||
? `${String(currentMatch + 1)}/${String(totalMatches)}`
|
||||
: 'no matches'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row" gap={1}>
|
||||
<Text>🔍</Text>
|
||||
<Box flexGrow={1}>
|
||||
<TextInput value={query} onChange={onQueryChange} focus={focused} />
|
||||
</Box>
|
||||
{matchDisplay && <Text dimColor>{matchDisplay}</Text>}
|
||||
<Text dimColor>↑↓ navigate · Esc close</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
143
packages/cli/src/tui/components/sidebar.tsx
Normal file
143
packages/cli/src/tui/components/sidebar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { ConversationSummary } from '../hooks/use-conversations.js';
|
||||
|
||||
export interface SidebarProps {
|
||||
conversations: ConversationSummary[];
|
||||
activeConversationId: string | undefined;
|
||||
selectedIndex: number;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onSwitchConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
loading: boolean;
|
||||
focused: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
const mon = months[date.getMonth()];
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${mon} ${dd}`;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
conversations,
|
||||
activeConversationId,
|
||||
selectedIndex,
|
||||
onSelectIndex,
|
||||
onSwitchConversation,
|
||||
onDeleteConversation,
|
||||
loading,
|
||||
focused,
|
||||
width,
|
||||
}: SidebarProps) {
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onSelectIndex(Math.max(0, selectedIndex - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
|
||||
}
|
||||
if (key.return) {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) {
|
||||
onSwitchConversation(conv.id);
|
||||
}
|
||||
}
|
||||
if (_input === 'd') {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) {
|
||||
onDeleteConversation(conv.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const borderColor = focused ? 'cyan' : 'gray';
|
||||
// Available width for content inside border + padding
|
||||
const innerWidth = width - 4; // 2 border + 2 padding
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
borderStyle="single"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Conversations
|
||||
</Text>
|
||||
<Box marginTop={0} flexDirection="column" flexGrow={1}>
|
||||
{loading && conversations.length === 0 ? (
|
||||
<Text dimColor>Loading…</Text>
|
||||
) : conversations.length === 0 ? (
|
||||
<Text dimColor>No conversations</Text>
|
||||
) : (
|
||||
conversations.map((conv, idx) => {
|
||||
const isActive = conv.id === activeConversationId;
|
||||
const isSelected = idx === selectedIndex && focused;
|
||||
const marker = isActive ? '● ' : ' ';
|
||||
const time = formatRelativeTime(conv.updatedAt);
|
||||
const title = conv.title ?? 'Untitled';
|
||||
// marker(2) + title + space(1) + time
|
||||
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
|
||||
const displayTitle = truncate(title, maxTitleLen);
|
||||
|
||||
return (
|
||||
<Box key={conv.id}>
|
||||
<Text
|
||||
inverse={isSelected}
|
||||
color={isActive ? 'cyan' : undefined}
|
||||
dimColor={!isActive && !isSelected}
|
||||
>
|
||||
{marker}
|
||||
{displayTitle}
|
||||
{' '.repeat(
|
||||
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
|
||||
)}
|
||||
{time}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
{focused && <Text dimColor>↑↓ navigate • enter switch • d delete</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
99
packages/cli/src/tui/components/top-bar.tsx
Normal file
99
packages/cli/src/tui/components/top-bar.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
export interface TopBarProps {
|
||||
gatewayUrl: string;
|
||||
version: string;
|
||||
modelName: string | null;
|
||||
thinkingLevel: string;
|
||||
contextWindow: number;
|
||||
agentName: string;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
}
|
||||
|
||||
/** Compact the URL — strip protocol */
|
||||
function compactHost(url: string): string {
|
||||
return url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
function formatContextWindow(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern)
|
||||
*
|
||||
* Layout:
|
||||
* blue ·· purple
|
||||
* ·· pink ··
|
||||
* amber ·· teal
|
||||
*/
|
||||
// Two-space gap between tiles (extracted to avoid prettier collapse)
|
||||
const GAP = ' ';
|
||||
|
||||
function MosaicIcon() {
|
||||
return (
|
||||
<Box flexDirection="column" marginRight={2}>
|
||||
<Text>
|
||||
<Text color="#2f80ff">██</Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#8b5cf6">██</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#ec4899">██</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="#f59e0b">██</Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#14b8a6">██</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
gatewayUrl,
|
||||
version,
|
||||
modelName,
|
||||
thinkingLevel,
|
||||
contextWindow,
|
||||
agentName,
|
||||
connected,
|
||||
connecting,
|
||||
}: TopBarProps) {
|
||||
const host = compactHost(gatewayUrl);
|
||||
const connectionIndicator = connected ? '●' : '○';
|
||||
const connectionColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||
|
||||
// Build model description line like: "claude-opus-4-6 (1M context) · default"
|
||||
const modelDisplay = modelName ?? 'awaiting model';
|
||||
const contextStr = contextWindow > 0 ? ` (${formatContextWindow(contextWindow)} context)` : '';
|
||||
const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : '';
|
||||
|
||||
return (
|
||||
<Box paddingX={1} paddingY={0} marginBottom={1}>
|
||||
<MosaicIcon />
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text>
|
||||
<Text bold color="#56a0ff">
|
||||
Mosaic Stack
|
||||
</Text>
|
||||
<Text dimColor> v{version}</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{modelDisplay}
|
||||
{contextStr}
|
||||
{thinkingStr} · {agentName}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color={connectionColor}>{connectionIndicator}</Text>
|
||||
<Text dimColor> {host}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
37
packages/cli/src/tui/hooks/use-app-mode.ts
Normal file
37
packages/cli/src/tui/hooks/use-app-mode.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type AppMode = 'chat' | 'sidebar' | 'search';
|
||||
|
||||
export interface UseAppModeReturn {
|
||||
mode: AppMode;
|
||||
setMode: (mode: AppMode) => void;
|
||||
toggleSidebar: () => void;
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
export function useAppMode(): UseAppModeReturn {
|
||||
const [mode, setModeState] = useState<AppMode>('chat');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const setMode = useCallback((next: AppMode) => {
|
||||
setModeState(next);
|
||||
if (next === 'sidebar') {
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarOpen((prev) => {
|
||||
if (prev) {
|
||||
// Closing sidebar — return to chat
|
||||
setModeState('chat');
|
||||
return false;
|
||||
}
|
||||
// Opening sidebar — set mode to sidebar
|
||||
setModeState('sidebar');
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { mode, setMode, toggleSidebar, sidebarOpen };
|
||||
}
|
||||
139
packages/cli/src/tui/hooks/use-conversations.ts
Normal file
139
packages/cli/src/tui/hooks/use-conversations.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsOptions {
|
||||
gatewayUrl: string;
|
||||
sessionCookie?: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsReturn {
|
||||
conversations: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
createConversation: (title?: string) => Promise<ConversationSummary | null>;
|
||||
deleteConversation: (id: string) => Promise<boolean>;
|
||||
renameConversation: (id: string, title: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
|
||||
const { gatewayUrl, sessionCookie } = opts;
|
||||
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const headers = useCallback((): Record<string, string> => {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (sessionCookie) h['Cookie'] = sessionCookie;
|
||||
return h;
|
||||
}, [sessionCookie]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary[];
|
||||
if (mountedRef.current) {
|
||||
setConversations(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [gatewayUrl, headers]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const createConversation = useCallback(
|
||||
async (title?: string): Promise<ConversationSummary | null> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ title: title ?? null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => [data, ...prev]);
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
return {
|
||||
conversations,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
};
|
||||
}
|
||||
29
packages/cli/src/tui/hooks/use-git-info.ts
Normal file
29
packages/cli/src/tui/hooks/use-git-info.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
export interface GitInfo {
|
||||
branch: string | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function useGitInfo(): GitInfo {
|
||||
const [info, setInfo] = useState<GitInfo>({
|
||||
branch: null,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
setInfo({ branch, cwd: process.cwd() });
|
||||
} catch {
|
||||
setInfo({ branch: null, cwd: process.cwd() });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
76
packages/cli/src/tui/hooks/use-search.ts
Normal file
76
packages/cli/src/tui/hooks/use-search.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import type { Message } from './use-socket.js';
|
||||
|
||||
export interface SearchMatch {
|
||||
messageIndex: number;
|
||||
charOffset: number;
|
||||
}
|
||||
|
||||
export interface UseSearchReturn {
|
||||
query: string;
|
||||
setQuery: (q: string) => void;
|
||||
matches: SearchMatch[];
|
||||
currentMatchIndex: number;
|
||||
nextMatch: () => void;
|
||||
prevMatch: () => void;
|
||||
clear: () => void;
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export function useSearch(messages: Message[]): UseSearchReturn {
|
||||
const [query, setQuery] = useState('');
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||
|
||||
const matches = useMemo<SearchMatch[]>(() => {
|
||||
if (query.length < 2) return [];
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const result: SearchMatch[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (!msg) continue;
|
||||
const content = msg.content.toLowerCase();
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const idx = content.indexOf(lowerQuery, offset);
|
||||
if (idx === -1) break;
|
||||
result.push({ messageIndex: i, charOffset: idx });
|
||||
offset = idx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [query, messages]);
|
||||
|
||||
// Reset match index when matches change
|
||||
useMemo(() => {
|
||||
setCurrentMatchIndex(0);
|
||||
}, [matches]);
|
||||
|
||||
const nextMatch = useCallback(() => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev + 1) % matches.length);
|
||||
}, [matches.length]);
|
||||
|
||||
const prevMatch = useCallback(() => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev - 1 + matches.length) % matches.length);
|
||||
}, [matches.length]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setQuery('');
|
||||
setCurrentMatchIndex(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
matches,
|
||||
currentMatchIndex,
|
||||
nextMatch,
|
||||
prevMatch,
|
||||
clear,
|
||||
totalMatches: matches.length,
|
||||
};
|
||||
}
|
||||
307
packages/cli/src/tui/hooks/use-socket.ts
Normal file
307
packages/cli/src/tui/hooks/use-socket.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { type MutableRefObject, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
MessageAckPayload,
|
||||
AgentEndPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ToolStartPayload,
|
||||
ToolEndPayload,
|
||||
SessionInfoPayload,
|
||||
ErrorPayload,
|
||||
CommandManifestPayload,
|
||||
} from '@mosaic/types';
|
||||
import { commandRegistry } from '../commands/index.js';
|
||||
|
||||
export interface ToolCall {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
status: 'running' | 'success' | 'error';
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant' | 'thinking' | 'tool' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: number;
|
||||
contextPercent: number;
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
export interface UseSocketOptions {
|
||||
gatewayUrl: string;
|
||||
sessionCookie?: string;
|
||||
initialConversationId?: string;
|
||||
initialModel?: string;
|
||||
initialProvider?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
|
||||
export interface UseSocketReturn {
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
messages: Message[];
|
||||
conversationId: string | undefined;
|
||||
isStreaming: boolean;
|
||||
currentStreamText: string;
|
||||
currentThinkingText: string;
|
||||
activeToolCalls: ToolCall[];
|
||||
tokenUsage: TokenUsage;
|
||||
modelName: string | null;
|
||||
providerName: string | null;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: string[];
|
||||
sendMessage: (content: string) => void;
|
||||
addSystemMessage: (content: string) => void;
|
||||
setThinkingLevel: (level: string) => void;
|
||||
switchConversation: (id: string) => void;
|
||||
clearMessages: () => void;
|
||||
connectionError: string | null;
|
||||
socketRef: MutableRefObject<TypedSocket | null>;
|
||||
}
|
||||
|
||||
const EMPTY_USAGE: TokenUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: 0,
|
||||
contextPercent: 0,
|
||||
contextWindow: 0,
|
||||
};
|
||||
|
||||
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
const {
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
initialConversationId,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
} = opts;
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(true);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
||||
const [currentThinkingText, setCurrentThinkingText] = useState('');
|
||||
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
|
||||
const [tokenUsage, setTokenUsage] = useState<TokenUsage>(EMPTY_USAGE);
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
|
||||
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<TypedSocket | null>(null);
|
||||
const conversationIdRef = useRef(conversationId);
|
||||
conversationIdRef.current = conversationId;
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io(`${gatewayUrl}/chat`, {
|
||||
transports: ['websocket'],
|
||||
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionAttempts: Infinity,
|
||||
}) as TypedSocket;
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
setConnected(true);
|
||||
setConnecting(false);
|
||||
setConnectionError(null);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
});
|
||||
|
||||
socket.io.on('error', (err: Error) => {
|
||||
setConnecting(false);
|
||||
setConnectionError(err.message);
|
||||
});
|
||||
|
||||
socket.on('message:ack', (data: MessageAckPayload) => {
|
||||
setConversationId(data.conversationId);
|
||||
});
|
||||
|
||||
socket.on('session:info', (data: SessionInfoPayload) => {
|
||||
setProviderName(data.provider);
|
||||
setModelName(data.modelId);
|
||||
setThinkingLevelState(data.thinkingLevel);
|
||||
setAvailableThinkingLevels(data.availableThinkingLevels);
|
||||
});
|
||||
|
||||
socket.on('agent:start', () => {
|
||||
setIsStreaming(true);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
});
|
||||
|
||||
socket.on('agent:text', (data: AgentTextPayload) => {
|
||||
setCurrentStreamText((prev) => prev + data.text);
|
||||
});
|
||||
|
||||
socket.on('agent:thinking', (data: AgentThinkingPayload) => {
|
||||
setCurrentThinkingText((prev) => prev + data.text);
|
||||
});
|
||||
|
||||
socket.on('agent:tool:start', (data: ToolStartPayload) => {
|
||||
setActiveToolCalls((prev) => [
|
||||
...prev,
|
||||
{ toolCallId: data.toolCallId, toolName: data.toolName, status: 'running' },
|
||||
]);
|
||||
});
|
||||
|
||||
socket.on('agent:tool:end', (data: ToolEndPayload) => {
|
||||
setActiveToolCalls((prev) =>
|
||||
prev.map((tc) =>
|
||||
tc.toolCallId === data.toolCallId
|
||||
? { ...tc, status: data.isError ? 'error' : 'success' }
|
||||
: tc,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('agent:end', (data: AgentEndPayload) => {
|
||||
setCurrentStreamText((prev) => {
|
||||
if (prev) {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'assistant', content: prev, timestamp: new Date() },
|
||||
]);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Update usage from the payload
|
||||
if (data.usage) {
|
||||
setProviderName(data.usage.provider);
|
||||
setModelName(data.usage.modelId);
|
||||
setThinkingLevelState(data.usage.thinkingLevel);
|
||||
setTokenUsage({
|
||||
input: data.usage.tokens.input,
|
||||
output: data.usage.tokens.output,
|
||||
total: data.usage.tokens.total,
|
||||
cacheRead: data.usage.tokens.cacheRead,
|
||||
cacheWrite: data.usage.tokens.cacheWrite,
|
||||
cost: data.usage.cost,
|
||||
contextPercent: data.usage.context.percent ?? 0,
|
||||
contextWindow: data.usage.context.window,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (data: ErrorPayload) => {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'assistant', content: `Error: ${data.error}`, timestamp: new Date() },
|
||||
]);
|
||||
setIsStreaming(false);
|
||||
});
|
||||
|
||||
socket.on('commands:manifest', (data: CommandManifestPayload) => {
|
||||
commandRegistry.updateManifest(data.manifest);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [gatewayUrl, sessionCookie]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (!content.trim() || isStreaming) return;
|
||||
if (!socketRef.current?.connected) return;
|
||||
|
||||
setMessages((msgs) => [...msgs, { role: 'user', content, timestamp: new Date() }]);
|
||||
|
||||
socketRef.current.emit('message', {
|
||||
conversationId,
|
||||
content,
|
||||
...(initialProvider ? { provider: initialProvider } : {}),
|
||||
...(initialModel ? { modelId: initialModel } : {}),
|
||||
...(agentId ? { agentId } : {}),
|
||||
});
|
||||
},
|
||||
[conversationId, isStreaming],
|
||||
);
|
||||
|
||||
const addSystemMessage = useCallback((content: string) => {
|
||||
setMessages((msgs) => [...msgs, { role: 'system', content, timestamp: new Date() }]);
|
||||
}, []);
|
||||
|
||||
const setThinkingLevel = useCallback((level: string) => {
|
||||
const cid = conversationIdRef.current;
|
||||
if (!socketRef.current?.connected || !cid) return;
|
||||
socketRef.current.emit('set:thinking', {
|
||||
conversationId: cid,
|
||||
level,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const switchConversation = useCallback(
|
||||
(id: string) => {
|
||||
clearMessages();
|
||||
setConversationId(id);
|
||||
},
|
||||
[clearMessages],
|
||||
);
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
messages,
|
||||
conversationId,
|
||||
isStreaming,
|
||||
currentStreamText,
|
||||
currentThinkingText,
|
||||
activeToolCalls,
|
||||
tokenUsage,
|
||||
modelName,
|
||||
providerName,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
sendMessage,
|
||||
addSystemMessage,
|
||||
setThinkingLevel,
|
||||
switchConversation,
|
||||
clearMessages,
|
||||
connectionError,
|
||||
socketRef,
|
||||
};
|
||||
}
|
||||
80
packages/cli/src/tui/hooks/use-viewport.ts
Normal file
80
packages/cli/src/tui/hooks/use-viewport.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useStdout } from 'ink';
|
||||
|
||||
export interface UseViewportOptions {
|
||||
totalItems: number;
|
||||
reservedLines?: number;
|
||||
}
|
||||
|
||||
export interface UseViewportReturn {
|
||||
scrollOffset: number;
|
||||
viewportSize: number;
|
||||
isScrolledUp: boolean;
|
||||
scrollToBottom: () => void;
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo: (offset: number) => void;
|
||||
canScrollUp: boolean;
|
||||
canScrollDown: boolean;
|
||||
}
|
||||
|
||||
export function useViewport({
|
||||
totalItems,
|
||||
reservedLines = 10,
|
||||
}: UseViewportOptions): UseViewportReturn {
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 24;
|
||||
const viewportSize = Math.max(1, rows - reservedLines);
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [autoFollow, setAutoFollow] = useState(true);
|
||||
|
||||
// Compute the maximum valid scroll offset
|
||||
const maxOffset = Math.max(0, totalItems - viewportSize);
|
||||
|
||||
// Auto-follow: when new items arrive and auto-follow is on, snap to bottom
|
||||
useEffect(() => {
|
||||
if (autoFollow) {
|
||||
setScrollOffset(maxOffset);
|
||||
}
|
||||
}, [autoFollow, maxOffset]);
|
||||
|
||||
const scrollTo = useCallback(
|
||||
(offset: number) => {
|
||||
const clamped = Math.max(0, Math.min(offset, maxOffset));
|
||||
setScrollOffset(clamped);
|
||||
setAutoFollow(clamped >= maxOffset);
|
||||
},
|
||||
[maxOffset],
|
||||
);
|
||||
|
||||
const scrollBy = useCallback(
|
||||
(delta: number) => {
|
||||
setScrollOffset((prev) => {
|
||||
const next = Math.max(0, Math.min(prev + delta, maxOffset));
|
||||
setAutoFollow(next >= maxOffset);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[maxOffset],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setScrollOffset(maxOffset);
|
||||
setAutoFollow(true);
|
||||
}, [maxOffset]);
|
||||
|
||||
const isScrolledUp = scrollOffset < maxOffset;
|
||||
const canScrollUp = scrollOffset > 0;
|
||||
const canScrollDown = scrollOffset < maxOffset;
|
||||
|
||||
return {
|
||||
scrollOffset,
|
||||
viewportSize,
|
||||
isScrolledUp,
|
||||
scrollToBottom,
|
||||
scrollBy,
|
||||
scrollTo,
|
||||
canScrollUp,
|
||||
canScrollDown,
|
||||
};
|
||||
}
|
||||
44
packages/db/drizzle/0002_nebulous_mimic.sql
Normal file
44
packages/db/drizzle/0002_nebulous_mimic.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
CREATE TABLE "team_members" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"team_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"role" text DEFAULT 'member' NOT NULL,
|
||||
"invited_by" text,
|
||||
"joined_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "teams" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"owner_id" text NOT NULL,
|
||||
"manager_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "teams_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "project_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "owner_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "system_prompt" text;--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "allowed_tools" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "skills" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "is_system" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "conversations" ADD COLUMN "agent_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "preferences" ADD COLUMN "mutable" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "projects" ADD COLUMN "team_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "projects" ADD COLUMN "owner_type" text DEFAULT 'user' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_invited_by_users_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_manager_id_users_id_fk" FOREIGN KEY ("manager_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "team_members_team_user_idx" ON "team_members" USING btree ("team_id","user_id");--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD CONSTRAINT "agents_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD CONSTRAINT "agents_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "agents_project_id_idx" ON "agents" USING btree ("project_id");--> statement-breakpoint
|
||||
CREATE INDEX "agents_owner_id_idx" ON "agents" USING btree ("owner_id");--> statement-breakpoint
|
||||
CREATE INDEX "agents_is_system_idx" ON "agents" USING btree ("is_system");--> statement-breakpoint
|
||||
CREATE INDEX "conversations_agent_id_idx" ON "conversations" USING btree ("agent_id");
|
||||
2435
packages/db/drizzle/meta/0002_snapshot.json
Normal file
2435
packages/db/drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1773602195609,
|
||||
"tag": "0001_magical_rattler",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1773625181629,
|
||||
"tag": "0002_nebulous_mimic",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
uuid,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
real,
|
||||
integer,
|
||||
customType,
|
||||
@@ -72,6 +73,44 @@ export const verifications = pgTable('verifications', {
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ─── Teams ───────────────────────────────────────────────────────────────────
|
||||
// Declared before projects because projects references teams.
|
||||
|
||||
export const teams = pgTable('teams', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
ownerId: text('owner_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'restrict' }),
|
||||
managerId: text('manager_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'restrict' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const teamMembers = pgTable(
|
||||
'team_members',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
teamId: uuid('team_id')
|
||||
.notNull()
|
||||
.references(() => teams.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
role: text('role', { enum: ['manager', 'member'] })
|
||||
.notNull()
|
||||
.default('member'),
|
||||
invitedBy: text('invited_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
uniq: uniqueIndex('team_members_team_user_idx').on(t.teamId, t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Brain ───────────────────────────────────────────────────────────────────
|
||||
// Declared before Chat because conversations references projects.
|
||||
|
||||
@@ -83,6 +122,10 @@ export const projects = pgTable('projects', {
|
||||
.notNull()
|
||||
.default('active'),
|
||||
ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }),
|
||||
ownerType: text('owner_type', { enum: ['user', 'team'] })
|
||||
.notNull()
|
||||
.default('user'),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -190,18 +233,32 @@ export const events = pgTable(
|
||||
(t) => [index('events_type_idx').on(t.type), index('events_date_idx').on(t.date)],
|
||||
);
|
||||
|
||||
export const agents = pgTable('agents', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
model: text('model').notNull(),
|
||||
status: text('status', { enum: ['idle', 'active', 'error', 'offline'] })
|
||||
.notNull()
|
||||
.default('idle'),
|
||||
config: jsonb('config'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
export const agents = pgTable(
|
||||
'agents',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
model: text('model').notNull(),
|
||||
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 +300,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 +308,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),
|
||||
],
|
||||
);
|
||||
@@ -304,6 +363,7 @@ export const preferences = pgTable(
|
||||
.notNull()
|
||||
.default('general'),
|
||||
source: text('source'),
|
||||
mutable: boolean('mutable').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import type {
|
||||
CommandManifestPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
} from '../commands/index.js';
|
||||
|
||||
export interface MessageAckPayload {
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
@@ -9,6 +16,26 @@ export interface AgentStartPayload {
|
||||
|
||||
export interface AgentEndPayload {
|
||||
conversationId: string;
|
||||
usage?: SessionUsagePayload;
|
||||
}
|
||||
|
||||
/** Session metadata emitted with agent:end and on session:info */
|
||||
export interface SessionUsagePayload {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
thinkingLevel: string;
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
};
|
||||
cost: number;
|
||||
context: {
|
||||
percent: number | null;
|
||||
window: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentTextPayload {
|
||||
@@ -42,6 +69,24 @@ export interface ErrorPayload {
|
||||
export interface ChatMessagePayload {
|
||||
conversationId?: string;
|
||||
content: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
/** Session info pushed when session is created or model changes */
|
||||
export interface SessionInfoPayload {
|
||||
conversationId: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: string[];
|
||||
}
|
||||
|
||||
/** Client request to change thinking level */
|
||||
export interface SetThinkingPayload {
|
||||
conversationId: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
/** Socket.IO typed event map: server → client */
|
||||
@@ -53,10 +98,16 @@ export interface ServerToClientEvents {
|
||||
'agent:thinking': (payload: AgentThinkingPayload) => void;
|
||||
'agent:tool:start': (payload: ToolStartPayload) => void;
|
||||
'agent:tool:end': (payload: ToolEndPayload) => void;
|
||||
'session:info': (payload: SessionInfoPayload) => void;
|
||||
'commands:manifest': (payload: CommandManifestPayload) => void;
|
||||
'command:result': (payload: SlashCommandResultPayload) => void;
|
||||
'system:reload': (payload: SystemReloadPayload) => void;
|
||||
error: (payload: ErrorPayload) => void;
|
||||
}
|
||||
|
||||
/** Socket.IO typed event map: client → server */
|
||||
export interface ClientToServerEvents {
|
||||
message: (data: ChatMessagePayload) => void;
|
||||
'set:thinking': (data: SetThinkingPayload) => void;
|
||||
'command:execute': (data: SlashCommandPayload) => void;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ export type {
|
||||
AgentThinkingPayload,
|
||||
ToolStartPayload,
|
||||
ToolEndPayload,
|
||||
SessionUsagePayload,
|
||||
SessionInfoPayload,
|
||||
SetThinkingPayload,
|
||||
ErrorPayload,
|
||||
ChatMessagePayload,
|
||||
ServerToClientEvents,
|
||||
|
||||
85
packages/types/src/commands/index.ts
Normal file
85
packages/types/src/commands/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/** Argument definition for a slash command */
|
||||
export interface CommandArgDef {
|
||||
name: string;
|
||||
type: 'string' | 'enum';
|
||||
optional: boolean;
|
||||
/** For enum type, the allowed values */
|
||||
values?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** A single command definition served by the gateway */
|
||||
export interface CommandDef {
|
||||
/** Command name without slash prefix, e.g. "model" */
|
||||
name: string;
|
||||
/** Short aliases, e.g. ["m"] */
|
||||
aliases: string[];
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
/** Argument schema */
|
||||
args?: CommandArgDef[];
|
||||
/** Nested subcommands (e.g. provider → login, logout) */
|
||||
subcommands?: CommandDef[];
|
||||
/** Origin of this command */
|
||||
scope: 'core' | 'agent' | 'skill' | 'plugin' | 'admin';
|
||||
/** Where the command executes */
|
||||
execution: 'local' | 'socket' | 'rest' | 'hybrid';
|
||||
/** Whether this command is currently available */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/** Full command manifest pushed from gateway to TUI */
|
||||
export interface CommandManifest {
|
||||
commands: CommandDef[];
|
||||
skills: SkillCommandDef[];
|
||||
/** Manifest version — TUI compares to detect changes */
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** Skill registered as /skill:name */
|
||||
export interface SkillCommandDef {
|
||||
/** Skill name (used as /skill:{name}) */
|
||||
name: string;
|
||||
description: string;
|
||||
/** Whether the skill is currently loaded and available */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/** Payload for commands:manifest event */
|
||||
export interface CommandManifestPayload {
|
||||
manifest: CommandManifest;
|
||||
}
|
||||
|
||||
/** Payload for system:reload broadcast */
|
||||
export interface SystemReloadPayload {
|
||||
commands: CommandDef[];
|
||||
skills: SkillCommandDef[];
|
||||
providers: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Client request to execute a slash command via socket */
|
||||
export interface SlashCommandPayload {
|
||||
conversationId: string;
|
||||
command: string;
|
||||
args?: string;
|
||||
}
|
||||
|
||||
/** Server response to a slash command */
|
||||
export interface SlashCommandResultPayload {
|
||||
conversationId: string;
|
||||
command: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Parsed slash command (TUI-side, not a socket type) */
|
||||
export interface ParsedCommand {
|
||||
/** Command name without slash, e.g. "model", "skill:brave-search" */
|
||||
command: string;
|
||||
/** Arguments string or null */
|
||||
args: string | null;
|
||||
/** Full raw input string */
|
||||
raw: string;
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './chat/index.js';
|
||||
export * from './agent/index.js';
|
||||
export * from './provider/index.js';
|
||||
export * from './routing/index.js';
|
||||
export * from './commands/index.js';
|
||||
|
||||
367
pnpm-lock.yaml
generated
367
pnpm-lock.yaml
generated
@@ -37,7 +37,7 @@ importers:
|
||||
version: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
apps/gateway:
|
||||
dependencies:
|
||||
@@ -127,7 +127,7 @@ importers:
|
||||
version: 0.34.48
|
||||
better-auth:
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.31.1))
|
||||
version: 1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
@@ -176,7 +176,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
@@ -185,7 +185,7 @@ importers:
|
||||
version: link:../../packages/design-tokens
|
||||
better-auth:
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.31.1))
|
||||
version: 1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))
|
||||
clsx:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.1
|
||||
@@ -220,6 +220,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
jsdom:
|
||||
specifier: ^29.0.0
|
||||
version: 29.0.0(@noble/hashes@2.0.1)
|
||||
tailwindcss:
|
||||
specifier: ^4.0.0
|
||||
version: 4.2.1
|
||||
@@ -228,7 +231,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/agent:
|
||||
dependencies:
|
||||
@@ -241,7 +244,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/auth:
|
||||
dependencies:
|
||||
@@ -250,7 +253,7 @@ importers:
|
||||
version: link:../db
|
||||
better-auth:
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.31.1))
|
||||
version: 1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
@@ -263,7 +266,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/brain:
|
||||
dependencies:
|
||||
@@ -279,10 +282,13 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/cli:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.1
|
||||
'@mosaic/mosaic':
|
||||
specifier: workspace:^
|
||||
version: link:../mosaic
|
||||
@@ -292,6 +298,9 @@ importers:
|
||||
'@mosaic/quality-rails':
|
||||
specifier: workspace:^
|
||||
version: link:../quality-rails
|
||||
'@mosaic/types':
|
||||
specifier: workspace:^
|
||||
version: link:../types
|
||||
commander:
|
||||
specifier: ^13.0.0
|
||||
version: 13.1.0
|
||||
@@ -325,7 +334,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/coord:
|
||||
dependencies:
|
||||
@@ -341,7 +350,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/db:
|
||||
dependencies:
|
||||
@@ -366,7 +375,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/design-tokens:
|
||||
devDependencies:
|
||||
@@ -375,7 +384,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/log:
|
||||
dependencies:
|
||||
@@ -391,7 +400,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/memory:
|
||||
dependencies:
|
||||
@@ -410,7 +419,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/mosaic:
|
||||
dependencies:
|
||||
@@ -438,7 +447,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/prdy:
|
||||
dependencies:
|
||||
@@ -466,7 +475,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/quality-rails:
|
||||
dependencies:
|
||||
@@ -482,7 +491,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/queue:
|
||||
dependencies:
|
||||
@@ -498,7 +507,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/types:
|
||||
dependencies:
|
||||
@@ -514,7 +523,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
plugins/discord:
|
||||
dependencies:
|
||||
@@ -533,7 +542,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
plugins/telegram:
|
||||
dependencies:
|
||||
@@ -549,7 +558,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -570,6 +579,17 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@asamuzakjp/css-color@5.0.1':
|
||||
resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/dom-selector@7.0.3':
|
||||
resolution: {integrity: sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -778,12 +798,52 @@ packages:
|
||||
'@borewit/text-codec@0.2.2':
|
||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||
hasBin: true
|
||||
|
||||
'@clack/core@0.4.1':
|
||||
resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==}
|
||||
|
||||
'@clack/prompts@0.9.1':
|
||||
resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==}
|
||||
|
||||
'@csstools/color-helpers@6.0.2':
|
||||
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@csstools/css-calc@3.1.1':
|
||||
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-color-parser@4.0.2':
|
||||
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-parser-algorithms@4.0.0':
|
||||
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.1':
|
||||
resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==}
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
css-tree:
|
||||
optional: true
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0':
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@discordjs/builders@1.13.1':
|
||||
resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
@@ -1446,6 +1506,15 @@ packages:
|
||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@exodus/bytes@1.15.0':
|
||||
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@noble/hashes': ^1.8.0 || ^2.0.0
|
||||
peerDependenciesMeta:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@fastify/ajv-compiler@4.0.5':
|
||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||
|
||||
@@ -3224,6 +3293,9 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
@@ -3422,6 +3494,10 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
css-tree@3.2.1:
|
||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -3433,6 +3509,10 @@ packages:
|
||||
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
data-urls@7.0.0:
|
||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -3442,6 +3522,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
deep-eql@5.0.2:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3627,6 +3710,10 @@ packages:
|
||||
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
environment@1.1.0:
|
||||
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4013,6 +4100,10 @@ packages:
|
||||
resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -4140,6 +4231,9 @@ packages:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
@@ -4171,6 +4265,15 @@ packages:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsdom@29.0.0:
|
||||
resolution: {integrity: sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
json-bigint@1.0.0:
|
||||
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
||||
|
||||
@@ -4352,6 +4455,10 @@ packages:
|
||||
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@11.2.7:
|
||||
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@7.18.3:
|
||||
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4371,6 +4478,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdn-data@2.27.1:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
|
||||
media-typer@1.1.0:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -4638,6 +4748,9 @@ packages:
|
||||
parse5@6.0.1:
|
||||
resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
|
||||
|
||||
parse5@8.0.0:
|
||||
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -4940,6 +5053,10 @@ packages:
|
||||
resolution: {integrity: sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
@@ -5141,6 +5258,9 @@ packages:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
tailwind-merge@3.5.0:
|
||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||
|
||||
@@ -5189,6 +5309,13 @@ packages:
|
||||
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tldts-core@7.0.25:
|
||||
resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==}
|
||||
|
||||
tldts@7.0.25:
|
||||
resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==}
|
||||
hasBin: true
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
@@ -5205,6 +5332,10 @@ packages:
|
||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
@@ -5212,6 +5343,10 @@ packages:
|
||||
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tr46@6.0.0:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
ts-algebra@2.0.0:
|
||||
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
|
||||
|
||||
@@ -5309,6 +5444,10 @@ packages:
|
||||
resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
undici@7.24.3:
|
||||
resolution: {integrity: sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -5389,6 +5528,10 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
web-streams-polyfill@3.3.3:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -5400,10 +5543,22 @@ packages:
|
||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
webidl-conversions@8.0.1:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
whatwg-mimetype@5.0.0:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-url@16.0.1:
|
||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
@@ -5464,6 +5619,13 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
xmlhttprequest-ssl@2.1.2:
|
||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -5537,6 +5699,24 @@ snapshots:
|
||||
optionalDependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
'@asamuzakjp/css-color@5.0.1':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
lru-cache: 11.2.7
|
||||
|
||||
'@asamuzakjp/dom-selector@7.0.3':
|
||||
dependencies:
|
||||
'@asamuzakjp/nwsapi': 2.3.9
|
||||
bidi-js: 1.0.3
|
||||
css-tree: 3.2.1
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.2.7
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
@@ -5967,6 +6147,10 @@ snapshots:
|
||||
|
||||
'@borewit/text-codec@0.2.2': {}
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
dependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@clack/core@0.4.1':
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
@@ -5978,6 +6162,30 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@csstools/color-helpers@6.0.2': {}
|
||||
|
||||
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 6.0.2
|
||||
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)':
|
||||
optionalDependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@discordjs/builders@1.13.1':
|
||||
dependencies:
|
||||
'@discordjs/formatters': 0.6.2
|
||||
@@ -6381,6 +6589,10 @@ snapshots:
|
||||
'@eslint/core': 0.17.0
|
||||
levn: 0.4.1
|
||||
|
||||
'@exodus/bytes@1.15.0(@noble/hashes@2.0.1)':
|
||||
optionalDependencies:
|
||||
'@noble/hashes': 2.0.1
|
||||
|
||||
'@fastify/ajv-compiler@4.0.5':
|
||||
dependencies:
|
||||
ajv: 8.18.0
|
||||
@@ -8373,7 +8585,7 @@ snapshots:
|
||||
|
||||
basic-ftp@5.2.0: {}
|
||||
|
||||
better-auth@1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)):
|
||||
better-auth@1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
||||
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))
|
||||
@@ -8399,7 +8611,7 @@ snapshots:
|
||||
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
vitest: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
|
||||
@@ -8412,6 +8624,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
bidi-js@1.0.3:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
body-parser@2.2.2:
|
||||
@@ -8596,16 +8812,30 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-tree@3.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
||||
data-uri-to-buffer@6.0.2: {}
|
||||
|
||||
data-urls@7.0.0(@noble/hashes@2.0.1):
|
||||
dependencies:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 16.0.1(@noble/hashes@2.0.1)
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
@@ -8729,6 +8959,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
environment@1.1.0: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
@@ -9298,6 +9530,12 @@ snapshots:
|
||||
dependencies:
|
||||
lru-cache: 11.2.6
|
||||
|
||||
html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.0(@noble/hashes@2.0.1)
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -9436,6 +9674,8 @@ snapshots:
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-stream@3.0.0: {}
|
||||
@@ -9460,6 +9700,32 @@ snapshots:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsdom@29.0.0(@noble/hashes@2.0.1):
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 5.0.1
|
||||
'@asamuzakjp/dom-selector': 7.0.3
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1)
|
||||
'@exodus/bytes': 1.15.0(@noble/hashes@2.0.1)
|
||||
css-tree: 3.2.1
|
||||
data-urls: 7.0.0(@noble/hashes@2.0.1)
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1)
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.2.7
|
||||
parse5: 8.0.0
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.1
|
||||
undici: 7.24.3
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 16.0.1(@noble/hashes@2.0.1)
|
||||
xml-name-validator: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
json-bigint@1.0.0:
|
||||
dependencies:
|
||||
bignumber.js: 9.3.1
|
||||
@@ -9629,6 +9895,8 @@ snapshots:
|
||||
|
||||
lru-cache@11.2.6: {}
|
||||
|
||||
lru-cache@11.2.7: {}
|
||||
|
||||
lru-cache@7.18.3: {}
|
||||
|
||||
magic-bytes.js@1.13.0: {}
|
||||
@@ -9641,6 +9909,8 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdn-data@2.27.1: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
memory-pager@1.5.0: {}
|
||||
@@ -9856,6 +10126,10 @@ snapshots:
|
||||
|
||||
parse5@6.0.1: {}
|
||||
|
||||
parse5@8.0.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
partial-json@0.1.7: {}
|
||||
@@ -10162,6 +10436,10 @@ snapshots:
|
||||
|
||||
sandwich-stream@2.0.2: {}
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -10421,6 +10699,8 @@ snapshots:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
||||
tailwindcss@4.2.1: {}
|
||||
@@ -10468,6 +10748,12 @@ snapshots:
|
||||
|
||||
tinyspy@3.0.2: {}
|
||||
|
||||
tldts-core@7.0.25: {}
|
||||
|
||||
tldts@7.0.25:
|
||||
dependencies:
|
||||
tldts-core: 7.0.25
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
@@ -10482,12 +10768,20 @@ snapshots:
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
dependencies:
|
||||
tldts: 7.0.25
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
tr46@5.1.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tr46@6.0.0:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
ts-algebra@2.0.0: {}
|
||||
|
||||
ts-api-utils@2.4.0(typescript@5.9.3):
|
||||
@@ -10569,6 +10863,8 @@ snapshots:
|
||||
|
||||
undici@7.24.0: {}
|
||||
|
||||
undici@7.24.3: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
uri-js@4.4.1:
|
||||
@@ -10609,7 +10905,7 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
lightningcss: 1.31.1
|
||||
|
||||
vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.31.1):
|
||||
vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.15)(lightningcss@1.31.1))
|
||||
@@ -10633,6 +10929,7 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.15
|
||||
jsdom: 29.0.0(@noble/hashes@2.0.1)
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
@@ -10644,17 +10941,33 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
whatwg-mimetype@5.0.0: {}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
dependencies:
|
||||
tr46: 5.1.1
|
||||
webidl-conversions: 7.0.0
|
||||
|
||||
whatwg-url@16.0.1(@noble/hashes@2.0.1):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.0(@noble/hashes@2.0.1)
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
@@ -10699,6 +11012,10 @@ snapshots:
|
||||
|
||||
ws@8.19.0: {}
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
xmlhttprequest-ssl@2.1.2: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
Reference in New Issue
Block a user