Compare commits
17 Commits
v0.0.8
...
3fcc03379a
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fcc03379a | |||
| 96409c40bf | |||
| 8628f4f93a | |||
| b649b5c987 | |||
| b4d03a8b49 | |||
| 85aeebbde2 | |||
| a4bb563779 | |||
| 7f6464bbda | |||
| f0741e045f | |||
| 5a1991924c | |||
| bd5d14d07f | |||
| d5a1791dc5 | |||
| bd81c12071 | |||
| 4da255bf04 | |||
| 82c10a7b33 | |||
| d31070177c | |||
| 3792576566 |
@@ -5,9 +5,10 @@ variables:
|
|||||||
when:
|
when:
|
||||||
- event: [push, pull_request, manual]
|
- event: [push, pull_request, manual]
|
||||||
|
|
||||||
# Turbo remote cache is at turbo.mosaicstack.dev (ducktors/turborepo-remote-cache).
|
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||||
# TURBO_TOKEN is a Woodpecker secret injected via from_secret into the environment.
|
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||||
# Turbo picks up TURBO_API, TURBO_TOKEN, and TURBO_TEAM automatically.
|
# 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:
|
steps:
|
||||||
install:
|
install:
|
||||||
@@ -18,11 +19,6 @@ steps:
|
|||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm typecheck
|
- pnpm typecheck
|
||||||
@@ -32,11 +28,6 @@ steps:
|
|||||||
# lint, format, and test are independent — run in parallel after typecheck
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
lint:
|
lint:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm lint
|
- pnpm lint
|
||||||
@@ -53,11 +44,6 @@ steps:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm test
|
- pnpm test
|
||||||
@@ -66,11 +52,6 @@ steps:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm build
|
- 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 { describe, expect, it, vi } from 'vitest';
|
||||||
import { ConversationsController } from '../conversations/conversations.controller.js';
|
import { ConversationsController } from '../conversations/conversations.controller.js';
|
||||||
import { MissionsController } from '../missions/missions.controller.js';
|
import { MissionsController } from '../missions/missions.controller.js';
|
||||||
@@ -25,12 +25,21 @@ function createBrain() {
|
|||||||
},
|
},
|
||||||
missions: {
|
missions: {
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findAllByUser: vi.fn(),
|
||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
|
findByIdAndUser: vi.fn(),
|
||||||
findByProject: vi.fn(),
|
findByProject: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
},
|
},
|
||||||
|
missionTasks: {
|
||||||
|
findByMissionAndUser: vi.fn(),
|
||||||
|
findByIdAndUser: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
findById: 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();
|
const brain = createBrain();
|
||||||
brain.missions.findById.mockResolvedValue({ id: 'mission-1', projectId: 'project-1' });
|
// findByIdAndUser returns undefined when the mission doesn't belong to the user
|
||||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
brain.missions.findByIdAndUser.mockResolvedValue(undefined);
|
||||||
const controller = new MissionsController(brain as never);
|
const controller = new MissionsController(brain as never);
|
||||||
|
|
||||||
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
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 { SkillLoaderService } from './skill-loader.service.js';
|
||||||
import { ProvidersController } from './providers.controller.js';
|
import { ProvidersController } from './providers.controller.js';
|
||||||
import { SessionsController } from './sessions.controller.js';
|
import { SessionsController } from './sessions.controller.js';
|
||||||
|
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule],
|
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
|
||||||
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
||||||
controllers: [ProvidersController, SessionsController],
|
controllers: [ProvidersController, SessionsController, AgentConfigsController],
|
||||||
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
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 {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
@@ -24,6 +24,9 @@ import { createGitTools } from './tools/git-tools.js';
|
|||||||
import { createShellTools } from './tools/shell-tools.js';
|
import { createShellTools } from './tools/shell-tools.js';
|
||||||
import { createWebTools } from './tools/web-tools.js';
|
import { createWebTools } from './tools/web-tools.js';
|
||||||
import type { SessionInfoDto } from './session.dto.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 {
|
export interface AgentSessionOptions {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -49,6 +52,14 @@ export interface AgentSessionOptions {
|
|||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
/** Whether the requesting user has admin privileges. Controls default tool access. */
|
/** Whether the requesting user has admin privileges. Controls default tool access. */
|
||||||
isAdmin?: boolean;
|
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 {
|
export interface AgentSession {
|
||||||
@@ -67,6 +78,8 @@ export interface AgentSession {
|
|||||||
sandboxDir: string;
|
sandboxDir: string;
|
||||||
/** Tool names available in this session, or null when all tools are available. */
|
/** Tool names available in this session, or null when all tools are available. */
|
||||||
allowedTools: string[] | null;
|
allowedTools: string[] | null;
|
||||||
|
/** User ID that owns this session, used for preference lookups. */
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -83,6 +96,13 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(CoordService) private readonly coordService: CoordService,
|
@Inject(CoordService) private readonly coordService: CoordService,
|
||||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
@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,
|
sessionId: string,
|
||||||
options?: AgentSessionOptions,
|
options?: AgentSessionOptions,
|
||||||
): Promise<AgentSession> {
|
): 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 providerName = model?.provider ?? 'default';
|
||||||
const modelId = model?.id ?? 'default';
|
const modelId = model?.id ?? 'default';
|
||||||
|
|
||||||
// Resolve sandbox directory: option > env var > process.cwd()
|
// Resolve sandbox directory: option > env var > process.cwd()
|
||||||
const sandboxDir =
|
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
|
// 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(
|
this.logger.log(
|
||||||
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
|
`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
|
// 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 =
|
const appendSystemPrompt =
|
||||||
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;
|
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;
|
||||||
|
|
||||||
@@ -255,6 +299,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
skillPromptAdditions: promptAdditions,
|
skillPromptAdditions: promptAdditions,
|
||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
|
userId: mergedOptions?.userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
@@ -338,8 +383,20 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
session.promptCount += 1;
|
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 {
|
try {
|
||||||
await session.piSession.prompt(message);
|
await session.piSession.prompt(effectiveMessage);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
||||||
@@ -375,6 +432,14 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
session.listeners.clear();
|
session.listeners.clear();
|
||||||
session.channels.clear();
|
session.channels.clear();
|
||||||
this.sessions.delete(sessionId);
|
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> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||||
import { resolve, relative, join } from 'node:path';
|
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
||||||
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write 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 };
|
const { path, encoding } = params as { path: string; encoding?: string };
|
||||||
let safePath: string;
|
let safePath: string;
|
||||||
try {
|
try {
|
||||||
safePath = resolveSafe(baseDir, path);
|
safePath = guardPath(path, baseDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
details: undefined,
|
details: undefined,
|
||||||
@@ -99,8 +92,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
|||||||
};
|
};
|
||||||
let safePath: string;
|
let safePath: string;
|
||||||
try {
|
try {
|
||||||
safePath = resolveSafe(baseDir, path);
|
safePath = guardPathUnsafe(path, baseDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
details: undefined,
|
details: undefined,
|
||||||
@@ -151,8 +150,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
|||||||
const target = path ?? '.';
|
const target = path ?? '.';
|
||||||
let safePath: string;
|
let safePath: string;
|
||||||
try {
|
try {
|
||||||
safePath = resolveSafe(baseDir, target);
|
safePath = guardPath(target, baseDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
details: undefined,
|
details: undefined,
|
||||||
|
|||||||
@@ -2,29 +2,13 @@ import { Type } from '@sinclair/typebox';
|
|||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import { exec } from 'node:child_process';
|
import { exec } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { resolve, relative } from 'node:path';
|
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
const GIT_TIMEOUT_MS = 15_000;
|
const GIT_TIMEOUT_MS = 15_000;
|
||||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
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(
|
async function runGit(
|
||||||
args: string[],
|
args: string[],
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
@@ -74,7 +58,21 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
|||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { cwd } = params as { cwd?: string };
|
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 result = await runGit(['status', '--short', '--branch'], safeCwd);
|
||||||
const text = result.error
|
const text = result.error
|
||||||
? `Error: ${result.error}\n${result.stderr}`
|
? `Error: ${result.error}\n${result.stderr}`
|
||||||
@@ -107,7 +105,21 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
|||||||
oneline?: boolean;
|
oneline?: boolean;
|
||||||
cwd?: 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
const args = ['log', `--max-count=${limit ?? 20}`];
|
const args = ['log', `--max-count=${limit ?? 20}`];
|
||||||
if (oneline !== false) args.push('--oneline');
|
if (oneline !== false) args.push('--oneline');
|
||||||
const result = await runGit(args, safeCwd);
|
const result = await runGit(args, safeCwd);
|
||||||
@@ -148,12 +160,43 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
|||||||
path?: string;
|
path?: string;
|
||||||
cwd?: 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'];
|
const args = ['diff'];
|
||||||
if (staged) args.push('--cached');
|
if (staged) args.push('--cached');
|
||||||
if (ref) args.push(ref);
|
if (ref) args.push(ref);
|
||||||
args.push('--');
|
args.push('--');
|
||||||
if (path) args.push(path);
|
if (safePath !== undefined) args.push(safePath);
|
||||||
const result = await runGit(args, safeCwd);
|
const result = await runGit(args, safeCwd);
|
||||||
const text = result.error
|
const text = result.error
|
||||||
? `Error: ${result.error}\n${result.stderr}`
|
? `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 } from '@sinclair/typebox';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import { spawn } from 'node:child_process';
|
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 DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
||||||
@@ -68,22 +68,6 @@ function extractBaseCommand(command: string): string {
|
|||||||
return firstToken.split('/').pop() ?? firstToken;
|
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(
|
function runCommand(
|
||||||
command: string,
|
command: string,
|
||||||
options: { timeoutMs: number; cwd?: 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 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, {
|
const result = await runCommand(command, {
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { SkillsModule } from './skills/skills.module.js';
|
|||||||
import { PluginModule } from './plugin/plugin.module.js';
|
import { PluginModule } from './plugin/plugin.module.js';
|
||||||
import { McpModule } from './mcp/mcp.module.js';
|
import { McpModule } from './mcp/mcp.module.js';
|
||||||
import { AdminModule } from './admin/admin.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 { ReloadModule } from './reload/reload.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -38,6 +42,10 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
PluginModule,
|
PluginModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
PreferencesModule,
|
||||||
|
CommandsModule,
|
||||||
|
GCModule,
|
||||||
|
ReloadModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -28,4 +28,8 @@ export class ChatSocketMessageDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import {
|
|||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
import type { Auth } from '@mosaic/auth';
|
import type { Auth } from '@mosaic/auth';
|
||||||
|
import type { SetThinkingPayload, SlashCommandPayload, SystemReloadPayload } from '@mosaic/types';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { AUTH } from '../auth/auth.tokens.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 { v4 as uuid } from 'uuid';
|
||||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||||
@@ -37,6 +40,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(AUTH) private readonly auth: Auth,
|
@Inject(AUTH) private readonly auth: Auth,
|
||||||
|
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||||
|
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -54,6 +59,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
client.data.user = session.user;
|
client.data.user = session.user;
|
||||||
client.data.session = session.session;
|
client.data.session = session.session;
|
||||||
this.logger.log(`Client connected: ${client.id}`);
|
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 {
|
handleDisconnect(client: Socket): void {
|
||||||
@@ -79,9 +87,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId);
|
let agentSession = this.agentService.getSession(conversationId);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
|
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||||
agentSession = await this.agentService.createSession(conversationId, {
|
agentSession = await this.agentService.createSession(conversationId, {
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
modelId: data.modelId,
|
modelId: data.modelId,
|
||||||
|
agentConfigId: data.agentId,
|
||||||
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -112,6 +123,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
// Track channel connection
|
// Track channel connection
|
||||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
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
|
// Send acknowledgment
|
||||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||||
|
|
||||||
@@ -130,6 +156,58 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastReload(payload: SystemReloadPayload): void {
|
||||||
|
this.server.emit('system:reload', payload);
|
||||||
|
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||||
|
}
|
||||||
|
|
||||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||||
if (!client.connected) {
|
if (!client.connected) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -143,9 +221,31 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
client.emit('agent:start', { conversationId });
|
client.emit('agent:start', { conversationId });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'agent_end':
|
case 'agent_end': {
|
||||||
client.emit('agent:end', { conversationId });
|
// 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;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'message_update': {
|
case 'message_update': {
|
||||||
const assistantEvent = event.assistantMessageEvent;
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
import { ChatGateway } from './chat.gateway.js';
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
import { ChatController } from './chat.controller.js';
|
import { ChatController } from './chat.controller.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [forwardRef(() => CommandsModule)],
|
||||||
controllers: [ChatController],
|
controllers: [ChatController],
|
||||||
providers: [ChatGateway],
|
providers: [ChatGateway],
|
||||||
|
exports: [ChatGateway],
|
||||||
})
|
})
|
||||||
export class ChatModule {}
|
export class ChatModule {}
|
||||||
|
|||||||
213
apps/gateway/src/commands/command-executor-p8012.spec.ts
Normal file
213
apps/gateway/src/commands/command-executor-p8012.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
|
import type { SlashCommandPayload } from '@mosaic/types';
|
||||||
|
|
||||||
|
// Minimal mock implementations
|
||||||
|
const mockRegistry = {
|
||||||
|
getManifest: vi.fn(() => ({
|
||||||
|
version: 1,
|
||||||
|
commands: [
|
||||||
|
{ name: 'provider', aliases: [], scope: 'agent', execution: 'hybrid', available: true },
|
||||||
|
{ name: 'mission', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAgentService = {
|
||||||
|
getSession: vi.fn(() => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSystemOverride = {
|
||||||
|
set: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
renew: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSessionGC = {
|
||||||
|
sweepOrphans: vi.fn(() => ({ orphanedSessions: 0, totalCleaned: [], duration: 0 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRedis = {
|
||||||
|
set: vi.fn().mockResolvedValue('OK'),
|
||||||
|
get: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildService(): CommandExecutorService {
|
||||||
|
return new CommandExecutorService(
|
||||||
|
mockRegistry as never,
|
||||||
|
mockAgentService as never,
|
||||||
|
mockSystemOverride as never,
|
||||||
|
mockSessionGC as never,
|
||||||
|
mockRedis as never,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CommandExecutorService — P8-012 commands', () => {
|
||||||
|
let service: CommandExecutorService;
|
||||||
|
const userId = 'user-123';
|
||||||
|
const conversationId = 'conv-456';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = buildService();
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider login — missing provider name
|
||||||
|
it('/provider login with no provider name returns usage error', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('Usage: /provider login');
|
||||||
|
expect(result.command).toBe('provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider login anthropic — success with URL containing poll token
|
||||||
|
it('/provider login <name> returns success with URL and poll token', async () => {
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'provider',
|
||||||
|
args: 'login anthropic',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('provider');
|
||||||
|
expect(result.message).toContain('anthropic');
|
||||||
|
expect(result.message).toContain('http');
|
||||||
|
// data should contain loginUrl and pollToken
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
|
const data = result.data as Record<string, unknown>;
|
||||||
|
expect(typeof data['loginUrl']).toBe('string');
|
||||||
|
expect(typeof data['pollToken']).toBe('string');
|
||||||
|
expect(data['loginUrl'] as string).toContain('anthropic');
|
||||||
|
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
|
||||||
|
// Verify Valkey was called
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||||
|
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||||
|
expect(key).toContain('mosaic:auth:poll:');
|
||||||
|
const stored = JSON.parse(value) as { status: string; provider: string; userId: string };
|
||||||
|
expect(stored.status).toBe('pending');
|
||||||
|
expect(stored.provider).toBe('anthropic');
|
||||||
|
expect(stored.userId).toBe(userId);
|
||||||
|
expect(ttl).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider with no args — returns usage
|
||||||
|
it('/provider with no args returns usage message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('Usage: /provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider list
|
||||||
|
it('/provider list returns success', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider logout with no name — usage error
|
||||||
|
it('/provider logout with no name returns error', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('Usage: /provider logout');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider unknown subcommand
|
||||||
|
it('/provider unknown subcommand returns error', async () => {
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'provider',
|
||||||
|
args: 'unknown',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('Unknown subcommand');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /mission status
|
||||||
|
it('/mission status returns stub message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('mission');
|
||||||
|
expect(result.message).toContain('Mission status');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /mission with no args
|
||||||
|
it('/mission with no args returns status stub', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('Mission status');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /mission set <id>
|
||||||
|
it('/mission set <id> returns confirmation', async () => {
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'mission',
|
||||||
|
args: 'set my-mission-123',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('my-mission-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /agent list
|
||||||
|
it('/agent list returns stub message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('agent');
|
||||||
|
expect(result.message).toContain('agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /agent with no args
|
||||||
|
it('/agent with no args returns usage', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('Usage: /agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /agent <id> — switch
|
||||||
|
it('/agent <id> returns switch confirmation', async () => {
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'agent',
|
||||||
|
args: 'my-agent-id',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('my-agent-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /prdy
|
||||||
|
it('/prdy returns PRD wizard message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('prdy');
|
||||||
|
expect(result.message).toContain('mosaic prdy');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /tools
|
||||||
|
it('/tools returns tools stub message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('tools');
|
||||||
|
expect(result.message).toContain('tools');
|
||||||
|
});
|
||||||
|
});
|
||||||
373
apps/gateway/src/commands/command-executor.service.ts
Normal file
373
apps/gateway/src/commands/command-executor.service.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
|
import type { QueueHandle } from '@mosaic/queue';
|
||||||
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
||||||
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
|
import { ReloadService } from '../reload/reload.service.js';
|
||||||
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
import { CommandRegistryService } from './command-registry.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,
|
||||||
|
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
|
@Optional()
|
||||||
|
@Inject(forwardRef(() => ReloadService))
|
||||||
|
private readonly reloadService: ReloadService | null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(forwardRef(() => ChatGateway))
|
||||||
|
private readonly chatGateway: ChatGateway | null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'agent':
|
||||||
|
return await this.handleAgent(args ?? null, conversationId);
|
||||||
|
case 'provider':
|
||||||
|
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||||
|
case 'mission':
|
||||||
|
return await this.handleMission(args ?? null, conversationId, userId);
|
||||||
|
case 'prdy':
|
||||||
|
return {
|
||||||
|
command: 'prdy',
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
'PRD wizard: run `mosaic prdy` in your project workspace to create or update a PRD.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
case 'tools':
|
||||||
|
return await this.handleTools(conversationId, userId);
|
||||||
|
case 'reload': {
|
||||||
|
if (!this.reloadService) {
|
||||||
|
return {
|
||||||
|
command: 'reload',
|
||||||
|
conversationId,
|
||||||
|
success: false,
|
||||||
|
message: 'ReloadService is not available.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reloadResult = await this.reloadService.reload('command');
|
||||||
|
this.chatGateway?.broadcastReload(reloadResult);
|
||||||
|
return {
|
||||||
|
command: 'reload',
|
||||||
|
success: true,
|
||||||
|
message: reloadResult.message,
|
||||||
|
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).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAgent(
|
||||||
|
args: string | null,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
if (!args) {
|
||||||
|
return {
|
||||||
|
command: 'agent',
|
||||||
|
success: true,
|
||||||
|
message: 'Usage: /agent <agent-id> to switch, or /agent list to see available agents.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args === 'list') {
|
||||||
|
return {
|
||||||
|
command: 'agent',
|
||||||
|
success: true,
|
||||||
|
message: 'Agent listing: use the web dashboard for full agent management.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch agent — stub for now (full implementation in P8-015)
|
||||||
|
return {
|
||||||
|
command: 'agent',
|
||||||
|
success: true,
|
||||||
|
message: `Agent switch to "${args}" requested. Restart conversation to apply.`,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleProvider(
|
||||||
|
args: string | null,
|
||||||
|
userId: string,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
if (!args) {
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: true,
|
||||||
|
message: 'Usage: /provider list | /provider login <name> | /provider logout <name>',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceIdx = args.indexOf(' ');
|
||||||
|
const subcommand = spaceIdx >= 0 ? args.slice(0, spaceIdx) : args;
|
||||||
|
const providerName = spaceIdx >= 0 ? args.slice(spaceIdx + 1).trim() : '';
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'list':
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: true,
|
||||||
|
message: 'Use the web dashboard to manage providers.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'login': {
|
||||||
|
if (!providerName) {
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: false,
|
||||||
|
message: 'Usage: /provider login <provider-name>',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pollToken = crypto.randomUUID();
|
||||||
|
const key = `mosaic:auth:poll:${pollToken}`;
|
||||||
|
// Store pending state in Valkey (TTL 5 minutes)
|
||||||
|
await this.redis.set(
|
||||||
|
key,
|
||||||
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
|
'EX',
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
// In production this would construct an OAuth URL
|
||||||
|
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: true,
|
||||||
|
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}\n\n(URL copied to clipboard)`,
|
||||||
|
conversationId,
|
||||||
|
data: { loginUrl, pollToken, provider: providerName },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'logout': {
|
||||||
|
if (!providerName) {
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: false,
|
||||||
|
message: 'Usage: /provider logout <provider-name>',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: true,
|
||||||
|
message: `Logout from ${providerName}: use the web dashboard to revoke provider tokens.`,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: false,
|
||||||
|
message: `Unknown subcommand: ${subcommand}. Use list, login, or logout.`,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMission(
|
||||||
|
args: string | null,
|
||||||
|
conversationId: string,
|
||||||
|
_userId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
if (!args || args === 'status') {
|
||||||
|
// TODO: fetch active mission from DB when MissionsService is available
|
||||||
|
return {
|
||||||
|
command: 'mission',
|
||||||
|
success: true,
|
||||||
|
message: 'Mission status: use the web dashboard for full mission management.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.startsWith('set ')) {
|
||||||
|
const missionId = args.slice(4).trim();
|
||||||
|
return {
|
||||||
|
command: 'mission',
|
||||||
|
success: true,
|
||||||
|
message: `Mission set to ${missionId}. Session context updated.`,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: 'mission',
|
||||||
|
success: true,
|
||||||
|
message: 'Usage: /mission [status|set <id>|list|tasks]',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTools(
|
||||||
|
conversationId: string,
|
||||||
|
_userId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
// TODO: fetch tool list from active agent session
|
||||||
|
return {
|
||||||
|
command: 'tools',
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
'Available tools depend on the active agent configuration. Use the web dashboard to configure tool access.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
273
apps/gateway/src/commands/command-registry.service.ts
Normal file
273
apps/gateway/src/commands/command-registry.service.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agent',
|
||||||
|
description: 'Switch or list available agents',
|
||||||
|
aliases: ['a'],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'args',
|
||||||
|
type: 'string',
|
||||||
|
optional: true,
|
||||||
|
description: 'list or <agent-id>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'provider',
|
||||||
|
description: 'Manage LLM providers (list/login/logout)',
|
||||||
|
aliases: [],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'args',
|
||||||
|
type: 'string',
|
||||||
|
optional: true,
|
||||||
|
description: 'list | login <name> | logout <name>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'hybrid',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mission',
|
||||||
|
description: 'View or set active mission',
|
||||||
|
aliases: [],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'args',
|
||||||
|
type: 'string',
|
||||||
|
optional: true,
|
||||||
|
description: 'status | set <id> | list | tasks',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'prdy',
|
||||||
|
description: 'Launch PRD wizard',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tools',
|
||||||
|
description: 'List available agent tools',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reload',
|
||||||
|
description: 'Soft-reload gateway plugins and command manifest (admin)',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'admin',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/gateway/src/commands/commands.module.ts
Normal file
37
apps/gateway/src/commands/commands.module.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||||
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
|
||||||
|
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
|
useFactory: (): QueueHandle => {
|
||||||
|
return createQueue();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: COMMANDS_REDIS,
|
||||||
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
|
},
|
||||||
|
CommandRegistryService,
|
||||||
|
CommandExecutorService,
|
||||||
|
],
|
||||||
|
exports: [CommandRegistryService, CommandExecutorService],
|
||||||
|
})
|
||||||
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
|
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
|
|
||||||
|
async onApplicationShutdown(): Promise<void> {
|
||||||
|
await this.handle.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/gateway/src/commands/commands.tokens.ts
Normal file
1
apps/gateway/src/commands/commands.tokens.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const COMMANDS_REDIS = 'COMMANDS_REDIS';
|
||||||
@@ -1,30 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
Inject,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { CoordService } from './coord.service.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). */
|
/** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */
|
||||||
function findMonorepoRoot(start: string): string {
|
function findMonorepoRoot(start: string): string {
|
||||||
@@ -57,13 +44,15 @@ function resolveAndValidatePath(raw: string | undefined): string {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File-based coord endpoints for agent tool consumption.
|
||||||
|
* DB-backed mission CRUD has moved to MissionsController at /api/missions.
|
||||||
|
*/
|
||||||
@Controller('api/coord')
|
@Controller('api/coord')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class CoordController {
|
export class CoordController {
|
||||||
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
||||||
|
|
||||||
// ── File-based coord endpoints (legacy) ──
|
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
async missionStatus(@Query('projectPath') projectPath?: string) {
|
async missionStatus(@Query('projectPath') projectPath?: string) {
|
||||||
const resolvedPath = resolveAndValidatePath(projectPath);
|
const resolvedPath = resolveAndValidatePath(projectPath);
|
||||||
@@ -85,121 +74,4 @@ export class CoordController {
|
|||||||
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
||||||
return detail;
|
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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import type { Brain } from '@mosaic/brain';
|
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
|
||||||
import {
|
import {
|
||||||
loadMission,
|
loadMission,
|
||||||
getMissionStatus,
|
getMissionStatus,
|
||||||
@@ -14,12 +12,14 @@ import {
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
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()
|
@Injectable()
|
||||||
export class CoordService {
|
export class CoordService {
|
||||||
private readonly logger = new Logger(CoordService.name);
|
private readonly logger = new Logger(CoordService.name);
|
||||||
|
|
||||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
|
||||||
|
|
||||||
async loadMission(projectPath: string): Promise<Mission | null> {
|
async loadMission(projectPath: string): Promise<Mission | null> {
|
||||||
try {
|
try {
|
||||||
return await loadMission(projectPath);
|
return await loadMission(projectPath);
|
||||||
@@ -74,68 +74,4 @@ export class CoordService {
|
|||||||
return [];
|
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';
|
} from '@nestjs/common';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { SummarizationService } from './summarization.service.js';
|
import { SummarizationService } from './summarization.service.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly tasks: cron.ScheduledTask[] = [];
|
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 {
|
onModuleInit(): void {
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
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(
|
this.tasks.push(
|
||||||
cron.schedule(summarizationSchedule, () => {
|
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(
|
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 { LogController } from './log.controller.js';
|
||||||
import { SummarizationService } from './summarization.service.js';
|
import { SummarizationService } from './summarization.service.js';
|
||||||
import { CronService } from './cron.service.js';
|
import { CronService } from './cron.service.js';
|
||||||
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [GCModule],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: LOG_SERVICE,
|
provide: LOG_SERVICE,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -17,33 +16,42 @@ import type { Brain } from '@mosaic/brain';
|
|||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { assertOwner } from '../auth/resource-ownership.js';
|
import {
|
||||||
import { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
|
CreateMissionDto,
|
||||||
|
UpdateMissionDto,
|
||||||
|
CreateMissionTaskDto,
|
||||||
|
UpdateMissionTaskDto,
|
||||||
|
} from './missions.dto.js';
|
||||||
|
|
||||||
@Controller('api/missions')
|
@Controller('api/missions')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class MissionsController {
|
export class MissionsController {
|
||||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||||
|
|
||||||
|
// ── Missions CRUD (user-scoped) ──
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list() {
|
async list(@CurrentUser() user: { id: string }) {
|
||||||
return this.brain.missions.findAll();
|
return this.brain.missions.findAllByUser(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
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()
|
@Post()
|
||||||
async create(@Body() dto: CreateMissionDto, @CurrentUser() user: { id: string }) {
|
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({
|
return this.brain.missions.create({
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
projectId: dto.projectId,
|
projectId: dto.projectId,
|
||||||
|
userId: user.id,
|
||||||
|
phase: dto.phase,
|
||||||
|
milestones: dto.milestones,
|
||||||
|
config: dto.config,
|
||||||
status: dto.status,
|
status: dto.status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -54,10 +62,8 @@ export class MissionsController {
|
|||||||
@Body() dto: UpdateMissionDto,
|
@Body() dto: UpdateMissionDto,
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
) {
|
) {
|
||||||
await this.getOwnedMission(id, user.id);
|
const existing = await this.brain.missions.findByIdAndUser(id, user.id);
|
||||||
if (dto.projectId) {
|
if (!existing) throw new NotFoundException('Mission not found');
|
||||||
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
|
|
||||||
}
|
|
||||||
const mission = await this.brain.missions.update(id, dto);
|
const mission = await this.brain.missions.update(id, dto);
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
if (!mission) throw new NotFoundException('Mission not found');
|
||||||
return mission;
|
return mission;
|
||||||
@@ -66,33 +72,81 @@ export class MissionsController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
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);
|
const deleted = await this.brain.missions.remove(id);
|
||||||
if (!deleted) throw new NotFoundException('Mission not found');
|
if (!deleted) throw new NotFoundException('Mission not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOwnedMission(id: string, userId: string) {
|
// ── Mission Tasks sub-routes ──
|
||||||
const mission = await this.brain.missions.findById(id);
|
|
||||||
|
@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');
|
if (!mission) throw new NotFoundException('Mission not found');
|
||||||
await this.getOwnedProject(mission.projectId, userId, 'Mission');
|
return this.brain.missionTasks.findByMissionAndUser(missionId, user.id);
|
||||||
return mission;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOwnedProject(
|
@Get(':missionId/tasks/:taskId')
|
||||||
projectId: string | null | undefined,
|
async getTask(
|
||||||
userId: string,
|
@Param('missionId') missionId: string,
|
||||||
resourceName: string,
|
@Param('taskId') taskId: string,
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
) {
|
) {
|
||||||
if (!projectId) {
|
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
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);
|
@Post(':missionId/tasks')
|
||||||
if (!project) {
|
async createTask(
|
||||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
@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);
|
@Patch(':missionId/tasks/:taskId')
|
||||||
return project;
|
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 missionStatuses = ['planning', 'active', 'paused', 'completed', 'failed'] as const;
|
||||||
|
const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
|
||||||
|
|
||||||
export class CreateMissionDto {
|
export class CreateMissionDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -19,6 +20,19 @@ export class CreateMissionDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(missionStatuses)
|
@IsIn(missionStatuses)
|
||||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
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 {
|
export class UpdateMissionDto {
|
||||||
@@ -40,7 +54,70 @@ export class UpdateMissionDto {
|
|||||||
@IsIn(missionStatuses)
|
@IsIn(missionStatuses)
|
||||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
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()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
metadata?: Record<string, unknown> | null;
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/gateway/src/reload/mosaic-plugin.interface.ts
Normal file
20
apps/gateway/src/reload/mosaic-plugin.interface.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export interface MosaicPlugin {
|
||||||
|
/** Called when the plugin is loaded/reloaded */
|
||||||
|
onLoad(): Promise<void>;
|
||||||
|
|
||||||
|
/** Called before the plugin is unloaded during reload */
|
||||||
|
onUnload(): Promise<void>;
|
||||||
|
|
||||||
|
/** Plugin identifier for registry */
|
||||||
|
readonly pluginName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMosaicPlugin(obj: unknown): obj is MosaicPlugin {
|
||||||
|
return (
|
||||||
|
typeof obj === 'object' &&
|
||||||
|
obj !== null &&
|
||||||
|
typeof (obj as MosaicPlugin).onLoad === 'function' &&
|
||||||
|
typeof (obj as MosaicPlugin).onUnload === 'function' &&
|
||||||
|
typeof (obj as MosaicPlugin).pluginName === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/gateway/src/reload/reload.controller.ts
Normal file
22
apps/gateway/src/reload/reload.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Controller, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import type { SystemReloadPayload } from '@mosaic/types';
|
||||||
|
import { AdminGuard } from '../admin/admin.guard.js';
|
||||||
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
|
import { ReloadService } from './reload.service.js';
|
||||||
|
|
||||||
|
@Controller('api/admin')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
export class ReloadController {
|
||||||
|
constructor(
|
||||||
|
@Inject(ReloadService) private readonly reloadService: ReloadService,
|
||||||
|
@Inject(ChatGateway) private readonly chatGateway: ChatGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('reload')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async triggerReload(): Promise<SystemReloadPayload> {
|
||||||
|
const result = await this.reloadService.reload('rest');
|
||||||
|
this.chatGateway.broadcastReload(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/gateway/src/reload/reload.module.ts
Normal file
14
apps/gateway/src/reload/reload.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
|
import { AdminGuard } from '../admin/admin.guard.js';
|
||||||
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
|
import { ReloadController } from './reload.controller.js';
|
||||||
|
import { ReloadService } from './reload.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => CommandsModule), forwardRef(() => ChatModule)],
|
||||||
|
controllers: [ReloadController],
|
||||||
|
providers: [ReloadService, AdminGuard],
|
||||||
|
exports: [ReloadService],
|
||||||
|
})
|
||||||
|
export class ReloadModule {}
|
||||||
106
apps/gateway/src/reload/reload.service.spec.ts
Normal file
106
apps/gateway/src/reload/reload.service.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ReloadService } from './reload.service.js';
|
||||||
|
|
||||||
|
function createMockCommandRegistry() {
|
||||||
|
return {
|
||||||
|
getManifest: vi.fn().mockReturnValue({
|
||||||
|
version: 1,
|
||||||
|
commands: [],
|
||||||
|
skills: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService() {
|
||||||
|
const registry = createMockCommandRegistry();
|
||||||
|
const service = new ReloadService(registry as never);
|
||||||
|
return { service, registry };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ReloadService', () => {
|
||||||
|
it('reload() calls onUnload then onLoad for registered MosaicPlugin', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
const mockPlugin = {
|
||||||
|
pluginName: 'test-plugin',
|
||||||
|
onLoad: vi.fn().mockImplementation(() => {
|
||||||
|
callOrder.push('onLoad');
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
onUnload: vi.fn().mockImplementation(() => {
|
||||||
|
callOrder.push('onUnload');
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
service.registerPlugin('test-plugin', mockPlugin);
|
||||||
|
const result = await service.reload('command');
|
||||||
|
|
||||||
|
expect(mockPlugin.onUnload).toHaveBeenCalledOnce();
|
||||||
|
expect(mockPlugin.onLoad).toHaveBeenCalledOnce();
|
||||||
|
expect(callOrder).toEqual(['onUnload', 'onLoad']);
|
||||||
|
expect(result.message).toContain('test-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reload() continues if one plugin throws during onUnload', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
const badPlugin = {
|
||||||
|
pluginName: 'bad-plugin',
|
||||||
|
onLoad: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onUnload: vi.fn().mockRejectedValue(new Error('unload failed')),
|
||||||
|
};
|
||||||
|
|
||||||
|
service.registerPlugin('bad-plugin', badPlugin);
|
||||||
|
const result = await service.reload('command');
|
||||||
|
|
||||||
|
expect(result.message).toContain('bad-plugin');
|
||||||
|
expect(result.message).toContain('unload failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reload() skips non-MosaicPlugin objects', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
const notAPlugin = { foo: 'bar' };
|
||||||
|
service.registerPlugin('not-a-plugin', notAPlugin);
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
const result = await service.reload('command');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.message).not.toContain('not-a-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reload() returns SystemReloadPayload with commands, skills, providers, message', async () => {
|
||||||
|
const { service, registry } = createService();
|
||||||
|
registry.getManifest.mockReturnValue({
|
||||||
|
version: 1,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
description: 'test cmd',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.reload('rest');
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('commands');
|
||||||
|
expect(result).toHaveProperty('skills');
|
||||||
|
expect(result).toHaveProperty('providers');
|
||||||
|
expect(result).toHaveProperty('message');
|
||||||
|
expect(result.commands).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registerPlugin() logs plugin registration', () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
// Should not throw and should register
|
||||||
|
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
92
apps/gateway/src/reload/reload.service.ts
Normal file
92
apps/gateway/src/reload/reload.service.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
type OnApplicationBootstrap,
|
||||||
|
type OnApplicationShutdown,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { SystemReloadPayload } from '@mosaic/types';
|
||||||
|
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||||
|
import { isMosaicPlugin } from './mosaic-plugin.interface.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReloadService implements OnApplicationBootstrap, OnApplicationShutdown {
|
||||||
|
private readonly logger = new Logger(ReloadService.name);
|
||||||
|
private readonly plugins: Map<string, unknown> = new Map();
|
||||||
|
private shutdownHandlerAttached = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onApplicationBootstrap(): void {
|
||||||
|
if (!this.shutdownHandlerAttached) {
|
||||||
|
process.on('SIGHUP', () => {
|
||||||
|
this.logger.log('SIGHUP received — triggering soft reload');
|
||||||
|
this.reload('sighup').catch((err: unknown) => {
|
||||||
|
this.logger.error(`SIGHUP reload failed: ${err}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.shutdownHandlerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onApplicationShutdown(): void {
|
||||||
|
process.removeAllListeners('SIGHUP');
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPlugin(name: string, plugin: unknown): void {
|
||||||
|
this.plugins.set(name, plugin);
|
||||||
|
this.logger.log(`Plugin registered: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft reload — unload plugins, reload plugins, broadcast.
|
||||||
|
* Does NOT restart the HTTP server or drop connections.
|
||||||
|
*/
|
||||||
|
async reload(
|
||||||
|
trigger: 'command' | 'rest' | 'sighup' | 'file-watch',
|
||||||
|
): Promise<SystemReloadPayload> {
|
||||||
|
this.logger.log(`Soft reload triggered by: ${trigger}`);
|
||||||
|
const reloaded: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 1. Unload all registered MosaicPlugin instances
|
||||||
|
for (const [name, plugin] of this.plugins) {
|
||||||
|
if (isMosaicPlugin(plugin)) {
|
||||||
|
try {
|
||||||
|
await plugin.onUnload();
|
||||||
|
reloaded.push(name);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`${name}: unload failed — ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Reload all MosaicPlugin instances
|
||||||
|
for (const [name, plugin] of this.plugins) {
|
||||||
|
if (isMosaicPlugin(plugin)) {
|
||||||
|
try {
|
||||||
|
await plugin.onLoad();
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`${name}: load failed — ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = this.commandRegistry.getManifest();
|
||||||
|
|
||||||
|
const errorSuffix = errors.length > 0 ? ` Errors: ${errors.join(', ')}` : '';
|
||||||
|
const payload: SystemReloadPayload = {
|
||||||
|
commands: manifest.commands,
|
||||||
|
skills: manifest.skills,
|
||||||
|
providers: [],
|
||||||
|
message: `Reload complete (trigger=${trigger}). Plugins reloaded: [${reloaded.join(', ')}].${errorSuffix}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Reload complete. Reloaded: [${reloaded.join(', ')}]. Errors: ${errors.length}`,
|
||||||
|
);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"jsdom": "^29.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
| id | status | milestone | description | pr | notes |
|
| id | status | milestone | description | pr | notes |
|
||||||
| ------ | ----------- | --------- | ------------------------------------------------------------------- | ---- | ------------- |
|
| ------ | ----------- | --------- | -------------------------------------------------------------------------------------------------- | ---- | ------------- |
|
||||||
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
||||||
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
| 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-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-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-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-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
|
||||||
| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 |
|
| 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-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 |
|
| 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-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-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-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-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
|
||||||
| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 |
|
| 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-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
|
||||||
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
||||||
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
|
| 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 |
|
| 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-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-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-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-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-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-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 |
|
| 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-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-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-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-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-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-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
|
||||||
| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 |
|
| 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 |
|
| 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-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
|
||||||
| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 |
|
| 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-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-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
|
||||||
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
||||||
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
||||||
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
|
| 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-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-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-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-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 |
|
| 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-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-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-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-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-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
||||||
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
| 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-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-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-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-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-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-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-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-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-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-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-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-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 |
|
| 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-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 |
|
| 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-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-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-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 |
|
| 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-005 | done | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | #158 | |
|
||||||
| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
| P8-006 | done | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | #158 | |
|
||||||
| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 |
|
| P8-007 | done | Phase 8 | DB migrations — preferences.mutable + teams + team_members + projects.teamId | #175 | #160 |
|
||||||
| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
| P8-008 | done | Phase 8 | @mosaic/types — CommandDef, CommandManifest, new socket events | #174 | #161 |
|
||||||
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
| 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
|
- Infrastructure: coord DB migration, agent sandbox hardening
|
||||||
- Quality: E2E Playwright suite (~35 tests), comprehensive docs (user/admin/dev/deployment)
|
- Quality: E2E Playwright suite (~35 tests), comprehensive docs (user/admin/dev/deployment)
|
||||||
- Fixes: TUI state updater, agent session sandboxing
|
- 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
|
||||||
44
docs/scratchpads/p8-012-agent-provider-commands.md
Normal file
44
docs/scratchpads/p8-012-agent-provider-commands.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# P8-012 Scratchpad — Gateway /agent, /provider, /mission, /prdy, /tools Commands
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add gateway-executed commands: `/agent`, `/provider`, `/mission`, `/prdy`, `/tools`.
|
||||||
|
Key feature: `/provider login` OAuth flow with Valkey poll token.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Read all relevant files (done)
|
||||||
|
2. Update `command-registry.service.ts` — add 5 new command registrations
|
||||||
|
3. Update `commands.module.ts` — wire Redis injection for executor
|
||||||
|
4. Update `command-executor.service.ts` — add 5 new command handlers + Redis injection
|
||||||
|
5. Write spec file for new commands
|
||||||
|
6. Run quality gates (typecheck, lint, format:check, test)
|
||||||
|
7. Commit and push
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- Redis pattern: same as GCModule — use `REDIS` token injected from a QueueHandle factory
|
||||||
|
- `CommandDef` type fields: `scope: 'core'|'agent'|'skill'|'plugin'|'admin'`, `args?: CommandArgDef[]`, `execution: 'local'|'socket'|'rest'|'hybrid'`
|
||||||
|
- No `category` or `usage` fields — instruction spec was wrong on that
|
||||||
|
- `SlashCommandResultPayload.conversationId` is typed as `string` (not `string | undefined`) per the type
|
||||||
|
- Provider commands are `scope: 'agent'` since they relate to agent configuration
|
||||||
|
- Redis injection: add a `COMMANDS_REDIS` token in commands module, inject via factory pattern same as GCModule
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [ ] command-registry.service.ts updated
|
||||||
|
- [ ] commands.module.ts updated (add Redis provider)
|
||||||
|
- [ ] command-executor.service.ts updated (add Redis injection + handlers)
|
||||||
|
- [ ] spec file written
|
||||||
|
- [ ] quality gates pass
|
||||||
|
- [ ] commit + push + PR
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- `conversationId` typing: `SlashCommandResultPayload.conversationId` is `string`, but some handler calls pass `undefined`. Need to check if it's optional.
|
||||||
|
|
||||||
|
After reviewing types: `conversationId: string` in `SlashCommandResultPayload` — not optional. Must pass empty string or actual ID. Looking at existing code: `message: 'Start a new conversation...'` returns `{ command, conversationId, ... }` where conversationId comes from payload which is always a string per `SlashCommandPayload`. For provider commands that don't have a conversationId, pass empty string `''` or the payload's conversationId.
|
||||||
|
|
||||||
|
Actually looking at the spec more carefully: `handleProvider` returns `conversationId: undefined`. But the type says `string`. This would be a TypeScript error. I'll use `''` as a fallback or adjust. Let me re-examine...
|
||||||
|
|
||||||
|
The `SlashCommandResultPayload` interface says `conversationId: string` — not optional. But the spec says `conversationId: undefined`. I'll use `payload.conversationId` (passing it through) since it comes from the payload.
|
||||||
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 { createMissionTasksRepo, type MissionTasksRepo } from './mission-tasks.js';
|
||||||
import { createTasksRepo, type TasksRepo } from './tasks.js';
|
import { createTasksRepo, type TasksRepo } from './tasks.js';
|
||||||
import { createConversationsRepo, type ConversationsRepo } from './conversations.js';
|
import { createConversationsRepo, type ConversationsRepo } from './conversations.js';
|
||||||
|
import { createAgentsRepo, type AgentsRepo } from './agents.js';
|
||||||
|
|
||||||
export interface Brain {
|
export interface Brain {
|
||||||
projects: ProjectsRepo;
|
projects: ProjectsRepo;
|
||||||
@@ -11,6 +12,7 @@ export interface Brain {
|
|||||||
missionTasks: MissionTasksRepo;
|
missionTasks: MissionTasksRepo;
|
||||||
tasks: TasksRepo;
|
tasks: TasksRepo;
|
||||||
conversations: ConversationsRepo;
|
conversations: ConversationsRepo;
|
||||||
|
agents: AgentsRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBrain(db: Db): Brain {
|
export function createBrain(db: Db): Brain {
|
||||||
@@ -20,5 +22,6 @@ export function createBrain(db: Db): Brain {
|
|||||||
missionTasks: createMissionTasksRepo(db),
|
missionTasks: createMissionTasksRepo(db),
|
||||||
tasks: createTasksRepo(db),
|
tasks: createTasksRepo(db),
|
||||||
conversations: createConversationsRepo(db),
|
conversations: createConversationsRepo(db),
|
||||||
|
agents: createAgentsRepo(db),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,9 @@ export {
|
|||||||
type Message,
|
type Message,
|
||||||
type NewMessage,
|
type NewMessage,
|
||||||
} from './conversations.js';
|
} 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"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clack/prompts": "^0.9.0",
|
||||||
"@mosaic/mosaic": "workspace:^",
|
"@mosaic/mosaic": "workspace:^",
|
||||||
"@mosaic/prdy": "workspace:^",
|
"@mosaic/prdy": "workspace:^",
|
||||||
"@mosaic/quality-rails": "workspace:^",
|
"@mosaic/quality-rails": "workspace:^",
|
||||||
|
"@mosaic/types": "workspace:^",
|
||||||
|
"commander": "^13.0.0",
|
||||||
"ink": "^5.0.0",
|
"ink": "^5.0.0",
|
||||||
"ink-text-input": "^6.0.0",
|
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0"
|
||||||
"commander": "^13.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { buildPrdyCli } from '@mosaic/prdy';
|
|
||||||
import { createQualityRailsCli } from '@mosaic/quality-rails';
|
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();
|
const program = new Command();
|
||||||
|
|
||||||
@@ -51,8 +53,17 @@ program
|
|||||||
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
||||||
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
|
.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('-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(
|
.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');
|
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
|
||||||
|
|
||||||
// Try loading saved session
|
// 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
|
// Dynamic import to avoid loading React/Ink for other commands
|
||||||
const { render } = await import('ink');
|
const { render } = await import('ink');
|
||||||
const React = await import('react');
|
const React = await import('react');
|
||||||
@@ -101,6 +156,9 @@ program
|
|||||||
sessionCookie: session.cookie,
|
sessionCookie: session.cookie,
|
||||||
initialModel: opts.model,
|
initialModel: opts.model,
|
||||||
initialProvider: opts.provider,
|
initialProvider: opts.provider,
|
||||||
|
agentId,
|
||||||
|
agentName: agentName ?? undefined,
|
||||||
|
projectId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -115,23 +173,12 @@ sessionsCmd
|
|||||||
.description('List active agent sessions')
|
.description('List active agent sessions')
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
.action(async (opts: { gateway: string }) => {
|
.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 { 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 {
|
try {
|
||||||
const result = await fetchSessions(opts.gateway, session.cookie);
|
const result = await fetchSessions(auth.gateway, auth.cookie);
|
||||||
if (result.total === 0) {
|
if (result.total === 0) {
|
||||||
console.log('No active sessions.');
|
console.log('No active sessions.');
|
||||||
return;
|
return;
|
||||||
@@ -193,23 +240,12 @@ sessionsCmd
|
|||||||
.description('Terminate an active agent session')
|
.description('Terminate an active agent session')
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
.action(async (id: string, opts: { gateway: string }) => {
|
.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 { 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 {
|
try {
|
||||||
await deleteSession(opts.gateway, session.cookie, id);
|
await deleteSession(auth.gateway, auth.cookie, id);
|
||||||
console.log(`Session ${id} destroyed.`);
|
console.log(`Session ${id} destroyed.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
@@ -217,13 +253,17 @@ sessionsCmd
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── prdy ───────────────────────────────────────────────────────────────
|
// ─── agent ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const prdyWrapper = buildPrdyCli();
|
registerAgentCommand(program);
|
||||||
const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy');
|
|
||||||
if (prdyCmd !== undefined) {
|
// ─── mission ───────────────────────────────────────────────────────────
|
||||||
program.addCommand(prdyCmd as unknown as Command);
|
|
||||||
}
|
registerMissionCommand(program);
|
||||||
|
|
||||||
|
// ─── prdy ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerPrdyCommand(program);
|
||||||
|
|
||||||
// ─── quality-rails ──────────────────────────────────────────────────────
|
// ─── 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 React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { Box, Text, useInput, useApp } from 'ink';
|
import { Box, useApp, useInput } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
import Spinner from 'ink-spinner';
|
import { TopBar } from './components/top-bar.js';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { BottomBar } from './components/bottom-bar.js';
|
||||||
import { fetchAvailableModels, type ModelInfo } from './gateway-api.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 {
|
export interface TuiAppProps {
|
||||||
role: 'user' | 'assistant' | 'system';
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TuiAppProps {
|
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
sessionCookie?: string;
|
sessionCookie?: string;
|
||||||
initialModel?: string;
|
initialModel?: string;
|
||||||
initialProvider?: string;
|
initialProvider?: string;
|
||||||
}
|
agentId?: string;
|
||||||
|
agentName?: string;
|
||||||
/**
|
projectId?: 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TuiApp({
|
export function TuiApp({
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
conversationId: initialConversationId,
|
conversationId,
|
||||||
sessionCookie,
|
sessionCookie,
|
||||||
initialModel,
|
initialModel,
|
||||||
initialProvider,
|
initialProvider,
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
projectId: _projectId,
|
||||||
}: TuiAppProps) {
|
}: TuiAppProps) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const gitInfo = useGitInfo();
|
||||||
const [input, setInput] = useState('');
|
const appMode = useAppMode();
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const [connected, setConnected] = useState(false);
|
|
||||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
|
||||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
|
||||||
|
|
||||||
// Model/provider state
|
const socket = useSocket({
|
||||||
const [currentModel, setCurrentModel] = useState<string | undefined>(initialModel);
|
gatewayUrl,
|
||||||
const [currentProvider, setCurrentProvider] = useState<string | undefined>(initialProvider);
|
sessionCookie,
|
||||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
initialConversationId: conversationId,
|
||||||
|
initialModel,
|
||||||
|
initialProvider,
|
||||||
|
agentId,
|
||||||
|
});
|
||||||
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||||
const currentStreamTextRef = useRef('');
|
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
fetchAvailableModels(gatewayUrl, sessionCookie)
|
if (currentMatch && appMode.mode === 'search') {
|
||||||
.then((models) => {
|
viewport.scrollTo(currentMatch.messageIndex);
|
||||||
setAvailableModels(models);
|
}
|
||||||
// If no model/provider specified and models are available, show the default
|
}, [currentMatch, appMode.mode, viewport]);
|
||||||
if (!initialModel && !initialProvider && models.length > 0) {
|
|
||||||
const first = models[0];
|
// Compute highlighted message indices for MessageList
|
||||||
if (first) {
|
const highlightedMessageIndices = useMemo(() => {
|
||||||
setCurrentModel(first.id);
|
if (search.matches.length === 0) return undefined;
|
||||||
setCurrentProvider(first.provider);
|
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;
|
||||||
}
|
}
|
||||||
})
|
case 'status':
|
||||||
.catch(() => {
|
case 's': {
|
||||||
// Non-fatal: TUI works without model list
|
const result = executeStatus(parsed, {
|
||||||
});
|
connected: socket.connected,
|
||||||
}, [gatewayUrl, sessionCookie, initialModel, initialProvider]);
|
model: socket.modelName,
|
||||||
|
provider: socket.providerName,
|
||||||
useEffect(() => {
|
sessionId: socket.conversationId ?? null,
|
||||||
const socket = io(`${gatewayUrl}/chat`, {
|
tokenCount: socket.tokenUsage.total,
|
||||||
transports: ['websocket'],
|
});
|
||||||
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
socket.addSystemMessage(result);
|
||||||
});
|
break;
|
||||||
|
|
||||||
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.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
return true;
|
case 'clear':
|
||||||
}
|
socket.clearMessages();
|
||||||
|
break;
|
||||||
if (command === 'provider') {
|
case 'stop':
|
||||||
if (args.length === 0) {
|
// Currently no stop mechanism exposed — show feedback
|
||||||
// List providers from available models
|
socket.addSystemMessage('Stop is not available for the current session.');
|
||||||
const providers = [...new Set(availableModels.map((m) => m.provider))];
|
break;
|
||||||
if (providers.length === 0) {
|
case 'cost': {
|
||||||
setMessages((msgs) => [
|
const u = socket.tokenUsage;
|
||||||
...msgs,
|
socket.addSystemMessage(
|
||||||
{
|
`Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`,
|
||||||
role: 'system',
|
);
|
||||||
content:
|
break;
|
||||||
'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.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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(
|
const handleGatewayCommand = useCallback(
|
||||||
(value: string) => {
|
(parsed: ParsedCommand) => {
|
||||||
if (!value.trim() || isStreaming) return;
|
if (!socket.socketRef.current?.connected || !socket.conversationId) {
|
||||||
|
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
||||||
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.' },
|
|
||||||
]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
socket.socketRef.current.emit('command:execute', {
|
||||||
setMessages((msgs) => [...msgs, { role: 'user', content: value }]);
|
conversationId: socket.conversationId,
|
||||||
|
command: parsed.command,
|
||||||
socketRef.current.emit('message', {
|
args: parsed.args ?? undefined,
|
||||||
conversationId,
|
|
||||||
content: value,
|
|
||||||
provider: currentProvider,
|
|
||||||
modelId: currentModel,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[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) => {
|
useInput((ch, key) => {
|
||||||
if (key.ctrl && ch === 'c') {
|
if (key.ctrl && ch === 'c') {
|
||||||
exit();
|
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
|
const inputPlaceholder =
|
||||||
? currentProvider
|
appMode.mode === 'sidebar'
|
||||||
? `${currentProvider}/${currentModel}`
|
? 'focus is on sidebar… press Esc to return'
|
||||||
: currentModel
|
: appMode.mode === 'search'
|
||||||
: null;
|
? '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 (
|
return (
|
||||||
<Box flexDirection="column" padding={1}>
|
<Box flexDirection="column" height="100%">
|
||||||
<Box marginBottom={1}>
|
<Box marginTop={1} />
|
||||||
<Text bold color="blue">
|
<TopBar
|
||||||
Mosaic
|
gatewayUrl={gatewayUrl}
|
||||||
</Text>
|
version="0.0.0"
|
||||||
<Text> </Text>
|
modelName={socket.modelName}
|
||||||
<Text dimColor>{connected ? `connected` : 'connecting...'}</Text>
|
thinkingLevel={socket.thinkingLevel}
|
||||||
{conversationId && <Text dimColor> | {conversationId.slice(0, 8)}</Text>}
|
contextWindow={socket.tokenUsage.contextWindow}
|
||||||
{modelLabel && (
|
agentName={agentName ?? 'default'}
|
||||||
<>
|
connected={socket.connected}
|
||||||
<Text dimColor> | </Text>
|
connecting={socket.connecting}
|
||||||
<Text color="yellow">{modelLabel}</Text>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
{appMode.sidebarOpen ? (
|
||||||
{messages.map((msg, i) => (
|
<Box flexDirection="row" flexGrow={1}>
|
||||||
<Box key={i} marginBottom={1}>
|
<Sidebar
|
||||||
{msg.role === 'system' ? (
|
conversations={conversations.conversations}
|
||||||
<Text dimColor italic>
|
activeConversationId={socket.conversationId}
|
||||||
{msg.content}
|
selectedIndex={sidebarSelectedIndex}
|
||||||
</Text>
|
onSelectIndex={setSidebarSelectedIndex}
|
||||||
) : (
|
onSwitchConversation={handleSwitchConversation}
|
||||||
<>
|
onDeleteConversation={handleDeleteConversation}
|
||||||
<Text bold color={msg.role === 'user' ? 'green' : 'cyan'}>
|
loading={conversations.loading}
|
||||||
{msg.role === 'user' ? '> ' : ' '}
|
focused={appMode.mode === 'sidebar'}
|
||||||
</Text>
|
width={30}
|
||||||
<Text wrap="wrap">{msg.content}</Text>
|
/>
|
||||||
</>
|
{messageArea}
|
||||||
)}
|
</Box>
|
||||||
</Box>
|
) : (
|
||||||
))}
|
<Box flexGrow={1}>{messageArea}</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{isStreaming && currentStreamText && (
|
<BottomBar
|
||||||
<Box marginBottom={1}>
|
gitInfo={gitInfo}
|
||||||
<Text bold color="cyan">
|
tokenUsage={socket.tokenUsage}
|
||||||
{' '}
|
connected={socket.connected}
|
||||||
</Text>
|
connecting={socket.connecting}
|
||||||
<Text wrap="wrap">{currentStreamText}</Text>
|
modelName={socket.modelName}
|
||||||
</Box>
|
providerName={socket.providerName}
|
||||||
)}
|
thinkingLevel={socket.thinkingLevel}
|
||||||
|
conversationId={socket.conversationId}
|
||||||
{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>
|
|
||||||
</Box>
|
</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 {
|
export interface ModelInfo {
|
||||||
@@ -30,10 +30,88 @@ export interface SessionListResult {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Agent Config types ──
|
||||||
* Fetch the list of available models from the gateway.
|
|
||||||
* Returns an empty array on network or auth errors so the TUI can still function.
|
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(
|
export async function fetchAvailableModels(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
sessionCookie?: 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(
|
export async function fetchProviders(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
sessionCookie?: string,
|
sessionCookie?: string,
|
||||||
@@ -76,28 +150,18 @@ export async function fetchProviders(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Session endpoints ──
|
||||||
* Fetch the list of active agent sessions from the gateway.
|
|
||||||
* Throws on network or auth errors.
|
|
||||||
*/
|
|
||||||
export async function fetchSessions(
|
export async function fetchSessions(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
sessionCookie: string,
|
sessionCookie: string,
|
||||||
): Promise<SessionListResult> {
|
): Promise<SessionListResult> {
|
||||||
const res = await fetch(`${gatewayUrl}/api/sessions`, {
|
const res = await fetch(`${gatewayUrl}/api/sessions`, {
|
||||||
headers: { Cookie: sessionCookie, Origin: gatewayUrl },
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
throw new Error(`Failed to list sessions (${res.status}): ${body}`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as SessionListResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy (terminate) an agent session on the gateway.
|
|
||||||
* Throws on network or auth errors.
|
|
||||||
*/
|
|
||||||
export async function deleteSession(
|
export async function deleteSession(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
sessionCookie: string,
|
sessionCookie: string,
|
||||||
@@ -105,10 +169,220 @@ export async function deleteSession(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Cookie: sessionCookie, Origin: gatewayUrl },
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 204) {
|
if (!res.ok && res.status !== 204) {
|
||||||
const body = await res.text().catch(() => '');
|
const body = await res.text().catch(() => '');
|
||||||
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
|
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,
|
"when": 1773602195609,
|
||||||
"tag": "0001_magical_rattler",
|
"tag": "0001_magical_rattler",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773625181629,
|
||||||
|
"tag": "0002_nebulous_mimic",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
jsonb,
|
jsonb,
|
||||||
index,
|
index,
|
||||||
|
uniqueIndex,
|
||||||
real,
|
real,
|
||||||
integer,
|
integer,
|
||||||
customType,
|
customType,
|
||||||
@@ -72,6 +73,44 @@ export const verifications = pgTable('verifications', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
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 ───────────────────────────────────────────────────────────────────
|
// ─── Brain ───────────────────────────────────────────────────────────────────
|
||||||
// Declared before Chat because conversations references projects.
|
// Declared before Chat because conversations references projects.
|
||||||
|
|
||||||
@@ -83,6 +122,10 @@ export const projects = pgTable('projects', {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default('active'),
|
.default('active'),
|
||||||
ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }),
|
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'),
|
metadata: jsonb('metadata'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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)],
|
(t) => [index('events_type_idx').on(t.type), index('events_date_idx').on(t.date)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const agents = pgTable('agents', {
|
export const agents = pgTable(
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
'agents',
|
||||||
name: text('name').notNull(),
|
{
|
||||||
provider: text('provider').notNull(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
model: text('model').notNull(),
|
name: text('name').notNull(),
|
||||||
status: text('status', { enum: ['idle', 'active', 'error', 'offline'] })
|
provider: text('provider').notNull(),
|
||||||
.notNull()
|
model: text('model').notNull(),
|
||||||
.default('idle'),
|
status: text('status', { enum: ['idle', 'active', 'error', 'offline'] })
|
||||||
config: jsonb('config'),
|
.notNull()
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
.default('idle'),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
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(
|
export const tickets = pgTable(
|
||||||
'tickets',
|
'tickets',
|
||||||
@@ -243,6 +300,7 @@ export const conversations = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
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),
|
archived: boolean('archived').notNull().default(false),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
@@ -250,6 +308,7 @@ export const conversations = pgTable(
|
|||||||
(t) => [
|
(t) => [
|
||||||
index('conversations_user_id_idx').on(t.userId),
|
index('conversations_user_id_idx').on(t.userId),
|
||||||
index('conversations_project_id_idx').on(t.projectId),
|
index('conversations_project_id_idx').on(t.projectId),
|
||||||
|
index('conversations_agent_id_idx').on(t.agentId),
|
||||||
index('conversations_archived_idx').on(t.archived),
|
index('conversations_archived_idx').on(t.archived),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -304,6 +363,7 @@ export const preferences = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default('general'),
|
.default('general'),
|
||||||
source: text('source'),
|
source: text('source'),
|
||||||
|
mutable: boolean('mutable').notNull().default(true),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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 {
|
export interface MessageAckPayload {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
@@ -9,6 +16,26 @@ export interface AgentStartPayload {
|
|||||||
|
|
||||||
export interface AgentEndPayload {
|
export interface AgentEndPayload {
|
||||||
conversationId: string;
|
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 {
|
export interface AgentTextPayload {
|
||||||
@@ -42,6 +69,24 @@ export interface ErrorPayload {
|
|||||||
export interface ChatMessagePayload {
|
export interface ChatMessagePayload {
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
content: 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 */
|
/** Socket.IO typed event map: server → client */
|
||||||
@@ -53,10 +98,16 @@ export interface ServerToClientEvents {
|
|||||||
'agent:thinking': (payload: AgentThinkingPayload) => void;
|
'agent:thinking': (payload: AgentThinkingPayload) => void;
|
||||||
'agent:tool:start': (payload: ToolStartPayload) => void;
|
'agent:tool:start': (payload: ToolStartPayload) => void;
|
||||||
'agent:tool:end': (payload: ToolEndPayload) => 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;
|
error: (payload: ErrorPayload) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Socket.IO typed event map: client → server */
|
/** Socket.IO typed event map: client → server */
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
message: (data: ChatMessagePayload) => void;
|
message: (data: ChatMessagePayload) => void;
|
||||||
|
'set:thinking': (data: SetThinkingPayload) => void;
|
||||||
|
'command:execute': (data: SlashCommandPayload) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export type {
|
|||||||
AgentThinkingPayload,
|
AgentThinkingPayload,
|
||||||
ToolStartPayload,
|
ToolStartPayload,
|
||||||
ToolEndPayload,
|
ToolEndPayload,
|
||||||
|
SessionUsagePayload,
|
||||||
|
SessionInfoPayload,
|
||||||
|
SetThinkingPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
ChatMessagePayload,
|
ChatMessagePayload,
|
||||||
ServerToClientEvents,
|
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 './agent/index.js';
|
||||||
export * from './provider/index.js';
|
export * from './provider/index.js';
|
||||||
export * from './routing/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)
|
version: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
apps/gateway:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -127,7 +127,7 @@ importers:
|
|||||||
version: 0.34.48
|
version: 0.34.48
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.5.5
|
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:
|
class-transformer:
|
||||||
specifier: ^0.5.1
|
specifier: ^0.5.1
|
||||||
version: 0.5.1
|
version: 0.5.1
|
||||||
@@ -176,7 +176,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -185,7 +185,7 @@ importers:
|
|||||||
version: link:../../packages/design-tokens
|
version: link:../../packages/design-tokens
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.5.5
|
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:
|
clsx:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@@ -220,6 +220,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.3(@types/react@19.2.14)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
|
jsdom:
|
||||||
|
specifier: ^29.0.0
|
||||||
|
version: 29.0.0(@noble/hashes@2.0.1)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@@ -228,7 +231,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/agent:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -241,7 +244,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/auth:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -250,7 +253,7 @@ importers:
|
|||||||
version: link:../db
|
version: link:../db
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.5.5
|
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:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
@@ -263,7 +266,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/brain:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -279,10 +282,13 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/cli:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@clack/prompts':
|
||||||
|
specifier: ^0.9.0
|
||||||
|
version: 0.9.1
|
||||||
'@mosaic/mosaic':
|
'@mosaic/mosaic':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../mosaic
|
version: link:../mosaic
|
||||||
@@ -292,6 +298,9 @@ importers:
|
|||||||
'@mosaic/quality-rails':
|
'@mosaic/quality-rails':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../quality-rails
|
version: link:../quality-rails
|
||||||
|
'@mosaic/types':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../types
|
||||||
commander:
|
commander:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.1.0
|
version: 13.1.0
|
||||||
@@ -325,7 +334,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/coord:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -341,7 +350,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -366,7 +375,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/design-tokens:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -375,7 +384,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/log:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -391,7 +400,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/memory:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -410,7 +419,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/mosaic:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -438,7 +447,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/prdy:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -466,7 +475,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/quality-rails:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -482,7 +491,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/queue:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -498,7 +507,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/types:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -514,7 +523,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
plugins/discord:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -533,7 +542,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
plugins/telegram:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -549,7 +558,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages:
|
||||||
|
|
||||||
@@ -570,6 +579,17 @@ packages:
|
|||||||
zod:
|
zod:
|
||||||
optional: true
|
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':
|
'@aws-crypto/crc32@5.2.0':
|
||||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -778,12 +798,52 @@ packages:
|
|||||||
'@borewit/text-codec@0.2.2':
|
'@borewit/text-codec@0.2.2':
|
||||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
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':
|
'@clack/core@0.4.1':
|
||||||
resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==}
|
resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==}
|
||||||
|
|
||||||
'@clack/prompts@0.9.1':
|
'@clack/prompts@0.9.1':
|
||||||
resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==}
|
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':
|
'@discordjs/builders@1.13.1':
|
||||||
resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==}
|
resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==}
|
||||||
engines: {node: '>=16.11.0'}
|
engines: {node: '>=16.11.0'}
|
||||||
@@ -1446,6 +1506,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
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':
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||||
|
|
||||||
@@ -3224,6 +3293,9 @@ packages:
|
|||||||
zod:
|
zod:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
bidi-js@1.0.3:
|
||||||
|
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||||
|
|
||||||
bignumber.js@9.3.1:
|
bignumber.js@9.3.1:
|
||||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||||
|
|
||||||
@@ -3422,6 +3494,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
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:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
@@ -3433,6 +3509,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
|
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
|
||||||
engines: {node: '>= 14'}
|
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:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -3442,6 +3522,9 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js@10.6.0:
|
||||||
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
deep-eql@5.0.2:
|
deep-eql@5.0.2:
|
||||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3627,6 +3710,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
entities@6.0.1:
|
||||||
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
environment@1.1.0:
|
environment@1.1.0:
|
||||||
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4013,6 +4100,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==}
|
resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
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:
|
http-errors@2.0.1:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -4140,6 +4231,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||||
engines: {node: '>=0.12.0'}
|
engines: {node: '>=0.12.0'}
|
||||||
|
|
||||||
|
is-potential-custom-element-name@1.0.1:
|
||||||
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
is-promise@4.0.0:
|
is-promise@4.0.0:
|
||||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||||
|
|
||||||
@@ -4171,6 +4265,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||||
hasBin: true
|
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:
|
json-bigint@1.0.0:
|
||||||
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
||||||
|
|
||||||
@@ -4352,6 +4455,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
||||||
engines: {node: 20 || >=22}
|
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:
|
lru-cache@7.18.3:
|
||||||
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4371,6 +4478,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
mdn-data@2.27.1:
|
||||||
|
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||||
|
|
||||||
media-typer@1.1.0:
|
media-typer@1.1.0:
|
||||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -4638,6 +4748,9 @@ packages:
|
|||||||
parse5@6.0.1:
|
parse5@6.0.1:
|
||||||
resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
|
resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
|
||||||
|
|
||||||
|
parse5@8.0.0:
|
||||||
|
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
||||||
|
|
||||||
parseurl@1.3.3:
|
parseurl@1.3.3:
|
||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -4940,6 +5053,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==}
|
resolution: {integrity: sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
saxes@6.0.0:
|
||||||
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
|
engines: {node: '>=v12.22.7'}
|
||||||
|
|
||||||
scheduler@0.23.2:
|
scheduler@0.23.2:
|
||||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||||
|
|
||||||
@@ -5141,6 +5258,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
symbol-tree@3.2.4:
|
||||||
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
tailwind-merge@3.5.0:
|
tailwind-merge@3.5.0:
|
||||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||||
|
|
||||||
@@ -5189,6 +5309,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
|
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
|
||||||
engines: {node: '>=14.0.0'}
|
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:
|
to-regex-range@5.0.1:
|
||||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
@@ -5205,6 +5332,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
tough-cookie@6.0.1:
|
||||||
|
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
tr46@0.0.3:
|
tr46@0.0.3:
|
||||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
@@ -5212,6 +5343,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
tr46@6.0.0:
|
||||||
|
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
ts-algebra@2.0.0:
|
ts-algebra@2.0.0:
|
||||||
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
|
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
|
||||||
|
|
||||||
@@ -5309,6 +5444,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==}
|
resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
|
undici@7.24.3:
|
||||||
|
resolution: {integrity: sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==}
|
||||||
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
unpipe@1.0.0:
|
unpipe@1.0.0:
|
||||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -5389,6 +5528,10 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
w3c-xmlserializer@5.0.0:
|
||||||
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
web-streams-polyfill@3.3.3:
|
web-streams-polyfill@3.3.3:
|
||||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -5400,10 +5543,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||||
engines: {node: '>=12'}
|
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:
|
whatwg-url@14.2.0:
|
||||||
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
||||||
engines: {node: '>=18'}
|
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:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
@@ -5464,6 +5619,13 @@ packages:
|
|||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
optional: true
|
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:
|
xmlhttprequest-ssl@2.1.2:
|
||||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
@@ -5537,6 +5699,24 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
zod: 4.3.6
|
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':
|
'@aws-crypto/crc32@5.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-crypto/util': 5.2.0
|
'@aws-crypto/util': 5.2.0
|
||||||
@@ -5967,6 +6147,10 @@ snapshots:
|
|||||||
|
|
||||||
'@borewit/text-codec@0.2.2': {}
|
'@borewit/text-codec@0.2.2': {}
|
||||||
|
|
||||||
|
'@bramus/specificity@2.4.2':
|
||||||
|
dependencies:
|
||||||
|
css-tree: 3.2.1
|
||||||
|
|
||||||
'@clack/core@0.4.1':
|
'@clack/core@0.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
@@ -5978,6 +6162,30 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
sisteransi: 1.0.5
|
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':
|
'@discordjs/builders@1.13.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@discordjs/formatters': 0.6.2
|
'@discordjs/formatters': 0.6.2
|
||||||
@@ -6381,6 +6589,10 @@ snapshots:
|
|||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
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':
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv: 8.18.0
|
ajv: 8.18.0
|
||||||
@@ -8373,7 +8585,7 @@ snapshots:
|
|||||||
|
|
||||||
basic-ftp@5.2.0: {}
|
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:
|
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/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))
|
'@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)
|
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: 19.2.4
|
||||||
react-dom: 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:
|
transitivePeerDependencies:
|
||||||
- '@cloudflare/workers-types'
|
- '@cloudflare/workers-types'
|
||||||
|
|
||||||
@@ -8412,6 +8624,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
|
|
||||||
|
bidi-js@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
bignumber.js@9.3.1: {}
|
bignumber.js@9.3.1: {}
|
||||||
|
|
||||||
body-parser@2.2.2:
|
body-parser@2.2.2:
|
||||||
@@ -8596,16 +8812,30 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
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: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
data-uri-to-buffer@4.0.1: {}
|
data-uri-to-buffer@4.0.1: {}
|
||||||
|
|
||||||
data-uri-to-buffer@6.0.2: {}
|
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:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
deep-eql@5.0.2: {}
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
@@ -8729,6 +8959,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.0
|
tapable: 2.3.0
|
||||||
|
|
||||||
|
entities@6.0.1: {}
|
||||||
|
|
||||||
environment@1.1.0: {}
|
environment@1.1.0: {}
|
||||||
|
|
||||||
es-define-property@1.0.1: {}
|
es-define-property@1.0.1: {}
|
||||||
@@ -9298,6 +9530,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 11.2.6
|
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:
|
http-errors@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
@@ -9436,6 +9674,8 @@ snapshots:
|
|||||||
|
|
||||||
is-number@7.0.0: {}
|
is-number@7.0.0: {}
|
||||||
|
|
||||||
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|
||||||
is-promise@4.0.0: {}
|
is-promise@4.0.0: {}
|
||||||
|
|
||||||
is-stream@3.0.0: {}
|
is-stream@3.0.0: {}
|
||||||
@@ -9460,6 +9700,32 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
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:
|
json-bigint@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
bignumber.js: 9.3.1
|
bignumber.js: 9.3.1
|
||||||
@@ -9629,6 +9895,8 @@ snapshots:
|
|||||||
|
|
||||||
lru-cache@11.2.6: {}
|
lru-cache@11.2.6: {}
|
||||||
|
|
||||||
|
lru-cache@11.2.7: {}
|
||||||
|
|
||||||
lru-cache@7.18.3: {}
|
lru-cache@7.18.3: {}
|
||||||
|
|
||||||
magic-bytes.js@1.13.0: {}
|
magic-bytes.js@1.13.0: {}
|
||||||
@@ -9641,6 +9909,8 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
|
mdn-data@2.27.1: {}
|
||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
|
|
||||||
memory-pager@1.5.0: {}
|
memory-pager@1.5.0: {}
|
||||||
@@ -9856,6 +10126,10 @@ snapshots:
|
|||||||
|
|
||||||
parse5@6.0.1: {}
|
parse5@6.0.1: {}
|
||||||
|
|
||||||
|
parse5@8.0.0:
|
||||||
|
dependencies:
|
||||||
|
entities: 6.0.1
|
||||||
|
|
||||||
parseurl@1.3.3: {}
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
partial-json@0.1.7: {}
|
partial-json@0.1.7: {}
|
||||||
@@ -10162,6 +10436,10 @@ snapshots:
|
|||||||
|
|
||||||
sandwich-stream@2.0.2: {}
|
sandwich-stream@2.0.2: {}
|
||||||
|
|
||||||
|
saxes@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
xmlchars: 2.2.0
|
||||||
|
|
||||||
scheduler@0.23.2:
|
scheduler@0.23.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -10421,6 +10699,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|
||||||
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
tailwind-merge@3.5.0: {}
|
tailwind-merge@3.5.0: {}
|
||||||
|
|
||||||
tailwindcss@4.2.1: {}
|
tailwindcss@4.2.1: {}
|
||||||
@@ -10468,6 +10748,12 @@ snapshots:
|
|||||||
|
|
||||||
tinyspy@3.0.2: {}
|
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:
|
to-regex-range@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
@@ -10482,12 +10768,20 @@ snapshots:
|
|||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
tough-cookie@6.0.1:
|
||||||
|
dependencies:
|
||||||
|
tldts: 7.0.25
|
||||||
|
|
||||||
tr46@0.0.3: {}
|
tr46@0.0.3: {}
|
||||||
|
|
||||||
tr46@5.1.1:
|
tr46@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
tr46@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
punycode: 2.3.1
|
||||||
|
|
||||||
ts-algebra@2.0.0: {}
|
ts-algebra@2.0.0: {}
|
||||||
|
|
||||||
ts-api-utils@2.4.0(typescript@5.9.3):
|
ts-api-utils@2.4.0(typescript@5.9.3):
|
||||||
@@ -10569,6 +10863,8 @@ snapshots:
|
|||||||
|
|
||||||
undici@7.24.0: {}
|
undici@7.24.0: {}
|
||||||
|
|
||||||
|
undici@7.24.3: {}
|
||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
@@ -10609,7 +10905,7 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
lightningcss: 1.31.1
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 2.1.9
|
'@vitest/expect': 2.1.9
|
||||||
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.15)(lightningcss@1.31.1))
|
'@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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.15
|
'@types/node': 22.19.15
|
||||||
|
jsdom: 29.0.0(@noble/hashes@2.0.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- less
|
- less
|
||||||
- lightningcss
|
- lightningcss
|
||||||
@@ -10644,17 +10941,33 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
|
w3c-xmlserializer@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
web-streams-polyfill@3.3.3: {}
|
web-streams-polyfill@3.3.3: {}
|
||||||
|
|
||||||
webidl-conversions@3.0.1: {}
|
webidl-conversions@3.0.1: {}
|
||||||
|
|
||||||
webidl-conversions@7.0.0: {}
|
webidl-conversions@7.0.0: {}
|
||||||
|
|
||||||
|
webidl-conversions@8.0.1: {}
|
||||||
|
|
||||||
|
whatwg-mimetype@5.0.0: {}
|
||||||
|
|
||||||
whatwg-url@14.2.0:
|
whatwg-url@14.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tr46: 5.1.1
|
tr46: 5.1.1
|
||||||
webidl-conversions: 7.0.0
|
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:
|
whatwg-url@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
@@ -10699,6 +11012,10 @@ snapshots:
|
|||||||
|
|
||||||
ws@8.19.0: {}
|
ws@8.19.0: {}
|
||||||
|
|
||||||
|
xml-name-validator@5.0.0: {}
|
||||||
|
|
||||||
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
xmlhttprequest-ssl@2.1.2: {}
|
xmlhttprequest-ssl@2.1.2: {}
|
||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user