Compare commits

...

31 Commits

Author SHA1 Message Date
Jarvis
116b91d2ae fix(packages): republish @mosaic/config and bump dependents
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
The published @mosaic/config@0.0.1 on the Gitea registry is the
stale tooling-configs package (tsconfig/eslint/prettier) with only
subpath exports. When the package was repurposed in 04a80fb9 as the
runtime config loader, its version was never bumped, so consumers
that pull from the registry still get the old tarball.

This caused `mosaic gateway install` to fail with
ERR_PACKAGE_PATH_NOT_EXPORTED when gateway imported loadConfig from
@mosaic/config at runtime.

- Bump @mosaic/config to 0.0.2 so CI publishes the runtime variant
- Bump @mosaic/gateway to 0.0.5 to republish with the fixed dep
  (0.1.0 was an unintended semver jump; deleted from registry to
  restore 0.0.x lineage)
- Bump @mosaic/mosaic to 0.0.19 so the CLI ships with the fixed
  transitive dep resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:51:09 -05:00
543388e18b fix(mosaic): resolve framework scripts via import.meta.url (#385)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Fixes #383 — resolveTool now uses fileURLToPath(import.meta.url). Adds package.json/framework subpath exports. Bumps @mosaic/mosaic to 0.0.18.
2026-04-05 01:41:46 +00:00
07a1f5d594 Merge pull request 'feat(mosaic): merge @mosaic/cli into @mosaic/mosaic' (#381) from fix/merge-cli-into-mosaic into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 01:11:33 +00:00
Jarvis
c6fc090c98 feat(mosaic): merge @mosaic/cli into @mosaic/mosaic
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
@mosaic/mosaic is now the single package providing both:
- 'mosaic' binary (CLI: yolo, coord, prdy, tui, gateway, etc.)
- 'mosaic-wizard' binary (installation wizard)

Changes:
- Move packages/cli/src/* into packages/mosaic/src/
- Convert dynamic @mosaic/mosaic imports to static relative imports
- Add CLI deps (ink, react, socket.io-client, @mosaic/config) to mosaic
- Add jsx: react-jsx to mosaic's tsconfig
- Exclude packages/cli from workspace (pnpm-workspace.yaml)
- Update install.sh to install @mosaic/mosaic instead of @mosaic/cli
- Bump version to 0.0.17

This eliminates the circular dependency between @mosaic/cli and
@mosaic/mosaic that was blocking the build graph.
2026-04-04 20:07:27 -05:00
9723b6b948 chore: bump @mosaic/cli and @mosaic/mosaic to 0.0.16 (#379)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 00:52:09 +00:00
c0d0fd44b7 refactor(storage): replace better-sqlite3 with PGlite adapter (#378)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 21:58:14 +00:00
30c0fb1308 fix: remediate npm deprecation warnings in @mosaic/gateway 0.0.3 (#377)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 21:03:54 +00:00
26fac4722f fix: gateway install preserves npm prefix via registry flag (#376)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 20:36:15 +00:00
e3f64c79d9 chore: move gateway default port from 4000 to 14242 (#375)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 20:17:40 +00:00
cbd5e8c626 fix: scope Gitea registry to @mosaic packages only (#374)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 19:09:14 +00:00
7560c7dee7 fix: gateway install uses Gitea registry instead of npmjs (#373)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-04 18:59:40 +00:00
982a0e8f83 chore: bump @mosaic/mosaic and @mosaic/cli to 0.0.11 (#372)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 18:47:03 +00:00
fc7fa11923 feat: local tier gateway with PGlite + Gitea-only publishing (#371)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-04 18:39:20 +00:00
86d6c214fe feat: gateway publishability + npmjs publish script (#370)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 18:07:05 +00:00
39ccba95d0 feat: mosaic gateway CLI daemon management + admin token auth (#369)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-04 18:03:12 +00:00
202e375f41 Merge pull request 'fix: add build tools to CI install step for better-sqlite3 native bindings' (#368) from feat/task-1775219952-fix-add-build-tools-to-ci-install-step-for-better-sqlite3 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 15:41:23 +00:00
Jarvis
d0378c5723 fix: add Alpine build tools before pnpm install in CI
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
2026-04-03 09:13:25 -05:00
d6f04a0757 Merge pull request 'fix: add build tools to CI install step for better-sqlite3 native bindings' (#366) from fix/storage-sqlite-ci into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 13:41:04 +00:00
afedb8697e Merge pull request 'fix: allow better-sqlite3 build script in pnpm 10' (#367) from fix/pnpm-build-scripts into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 13:11:07 +00:00
Jarvis
1274df7ffc fix: allow better-sqlite3 build script in pnpm 10
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-04-03 08:06:01 -05:00
Jarvis
1b4767bd8b fix: add build tools to CI install step for better-sqlite3 native bindings
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
2026-04-03 07:41:39 -05:00
0b0fe10b37 Merge pull request 'feat: storage abstraction retrofit — adapters for queue, storage, memory (phases 1-4)' (#365) from feat/storage-abstraction into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 04:40:57 +00:00
acfb31f8f6 fix: quality-rails Commander version mismatch + installer defaults (#364)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 02:40:02 +00:00
d4c5797a65 fix: installer copies default framework files (AGENTS.md) to mosaicHome (#363)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:34:43 +00:00
70a51ba711 fix: all CLI script resolution uses bundled-first resolveTool() (#362)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:28:07 +00:00
db8023bdbb fix: fwScript prefers npm-bundled scripts over stale deployed copies (#361)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:21:58 +00:00
9e597ecf87 chore: bump @mosaic/mosaic and @mosaic/cli to 0.0.6 (#360)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:13:37 +00:00
a23c117ea4 fix: auto-migrate customized skills to skills-local/ on sync (#359)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-03 02:11:03 +00:00
0cf80dab8c fix: stale update banner + skill sync dirty worktree crash (#358)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:04:05 +00:00
381b0eed7b Merge pull request 'chore: bump @mosaic/mosaic and @mosaic/cli to 0.0.4' (#357) from chore/bump-0.0.4 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: mosaic/mosaic-stack#357
2026-04-03 01:51:55 +00:00
Jarvis
e7db9ddf98 chore: bump @mosaic/mosaic and @mosaic/cli to 0.0.4
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-02 20:50:44 -05:00
120 changed files with 9699 additions and 1685 deletions

View File

@@ -23,8 +23,8 @@ VALKEY_URL=redis://localhost:6380
# ─── Gateway ─────────────────────────────────────────────────────────────────
# TCP port the NestJS/Fastify gateway listens on (default: 4000)
GATEWAY_PORT=4000
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
GATEWAY_PORT=14242
# Comma-separated list of allowed CORS origins.
# Must include the web app origin in production.
@@ -37,12 +37,12 @@ GATEWAY_CORS_ORIGIN=http://localhost:3000
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
# Public base URL of the gateway (used by BetterAuth for callback URLs)
BETTER_AUTH_URL=http://localhost:4000
BETTER_AUTH_URL=http://localhost:14242
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
# Public gateway URL — accessible from the browser, not just the server.
NEXT_PUBLIC_GATEWAY_URL=http://localhost:4000
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
@@ -121,12 +121,12 @@ OTEL_SERVICE_NAME=mosaic-gateway
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
# DISCORD_BOT_TOKEN=
# DISCORD_GUILD_ID=
# DISCORD_GATEWAY_URL=http://localhost:4000
# DISCORD_GATEWAY_URL=http://localhost:14242
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_GATEWAY_URL=http://localhost:4000
# TELEGRAM_GATEWAY_URL=http://localhost:14242
# ─── SSO Providers (add credentials to enable) ───────────────────────────────

View File

@@ -15,6 +15,7 @@ steps:
image: *node_image
commands:
- corepack enable
- apk add --no-cache python3 make g++
- pnpm install --frozen-lockfile
typecheck:

View File

@@ -35,17 +35,31 @@ steps:
- |
echo "//git.mosaicstack.dev/api/packages/mosaic/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/" >> ~/.npmrc
# Publish all non-private packages (--no-git-checks skips dirty/branch checks in CI)
# --filter excludes private apps (gateway, web) and the root
# Publish non-private packages to Gitea (--no-git-checks skips dirty/branch checks in CI)
# --filter excludes web (private)
- >
pnpm --filter "@mosaic/*"
--filter "!@mosaic/gateway"
--filter "!@mosaic/web"
publish --no-git-checks --access public
|| echo "[publish] Some packages may already exist at this version — continuing"
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
# image: *node_image
# environment:
# NPM_TOKEN:
# from_secret: npmjs_token
# commands:
# - *enable_pnpm
# - apk add --no-cache jq bash
# - bash scripts/publish-npmjs.sh
# depends_on:
# - build
# when:
# - event: [tag]
build-gateway:
image: gcr.io/kaniko-project/executor:debug
environment:

View File

@@ -1,9 +1,23 @@
{
"name": "@mosaic/gateway",
"version": "0.0.2",
"private": true,
"version": "0.0.5",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "apps/gateway"
},
"type": "module",
"main": "dist/main.js",
"bin": {
"mosaic-gateway": "dist/main.js"
},
"files": [
"dist"
],
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm/",
"access": "public"
},
"scripts": {
"build": "tsc",
"dev": "tsx watch src/main.ts",
@@ -14,8 +28,8 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@fastify/helmet": "^13.0.2",
"@mariozechner/pi-ai": "~0.57.1",
"@mariozechner/pi-coding-agent": "~0.57.1",
"@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@mosaic/auth": "workspace:^",
"@mosaic/brain": "workspace:^",
@@ -35,7 +49,7 @@
"@nestjs/platform-socket.io": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.0.0",
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
"@opentelemetry/auto-instrumentations-node": "^0.72.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
"@opentelemetry/resources": "^2.6.0",

View File

@@ -0,0 +1,90 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Inject,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto';
import { eq, type Db, adminTokens } from '@mosaic/db';
import { v4 as uuid } from 'uuid';
import { DB } from '../database/database.module.js';
import { AdminGuard } from './admin.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import type {
CreateTokenDto,
TokenCreatedDto,
TokenDto,
TokenListDto,
} from './admin-tokens.dto.js';
function hashToken(plaintext: string): string {
return createHash('sha256').update(plaintext).digest('hex');
}
function toTokenDto(row: typeof adminTokens.$inferSelect): TokenDto {
return {
id: row.id,
label: row.label,
scope: row.scope,
expiresAt: row.expiresAt?.toISOString() ?? null,
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
@Controller('api/admin/tokens')
@UseGuards(AdminGuard)
export class AdminTokensController {
constructor(@Inject(DB) private readonly db: Db) {}
@Post()
async create(
@Body() dto: CreateTokenDto,
@CurrentUser() user: { id: string },
): Promise<TokenCreatedDto> {
const plaintext = randomBytes(32).toString('hex');
const tokenHash = hashToken(plaintext);
const id = uuid();
const expiresAt = dto.expiresInDays
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
: null;
const [row] = await this.db
.insert(adminTokens)
.values({
id,
userId: user.id,
tokenHash,
label: dto.label ?? 'CLI token',
scope: dto.scope ?? 'admin',
expiresAt,
})
.returning();
return { ...toTokenDto(row!), plaintext };
}
@Get()
async list(@CurrentUser() user: { id: string }): Promise<TokenListDto> {
const rows = await this.db
.select()
.from(adminTokens)
.where(eq(adminTokens.userId, user.id))
.orderBy(adminTokens.createdAt);
return { tokens: rows.map(toTokenDto), total: rows.length };
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async revoke(@Param('id') id: string, @CurrentUser() _user: { id: string }): Promise<void> {
await this.db.delete(adminTokens).where(eq(adminTokens.id, id));
}
}

View File

@@ -0,0 +1,33 @@
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
export class CreateTokenDto {
@IsString()
label!: string;
@IsOptional()
@IsString()
scope?: string;
@IsOptional()
@IsInt()
@Min(1)
expiresInDays?: number;
}
export interface TokenDto {
id: string;
label: string;
scope: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
}
export interface TokenCreatedDto extends TokenDto {
plaintext: string;
}
export interface TokenListDto {
tokens: TokenDto[];
total: number;
}

View File

@@ -6,10 +6,11 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaic/auth';
import type { Db } from '@mosaic/db';
import { eq, users as usersTable } from '@mosaic/db';
import { eq, adminTokens, users as usersTable } from '@mosaic/db';
import type { FastifyRequest } from 'fastify';
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
@@ -19,6 +20,8 @@ interface UserWithRole {
role?: string;
}
type AuthenticatedRequest = FastifyRequest & { user: unknown; session: unknown };
@Injectable()
export class AdminGuard implements CanActivate {
constructor(
@@ -28,8 +31,64 @@ export class AdminGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const headers = fromNodeHeaders(request.raw.headers);
// Try bearer token auth first
const authHeader = request.raw.headers['authorization'];
if (authHeader?.startsWith('Bearer ')) {
return this.validateBearerToken(request, authHeader.slice(7));
}
// Fall back to BetterAuth session
return this.validateSession(request);
}
private async validateBearerToken(request: FastifyRequest, plaintext: string): Promise<boolean> {
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const [row] = await this.db
.select({
tokenId: adminTokens.id,
userId: adminTokens.userId,
scope: adminTokens.scope,
expiresAt: adminTokens.expiresAt,
userName: usersTable.name,
userEmail: usersTable.email,
userRole: usersTable.role,
})
.from(adminTokens)
.innerJoin(usersTable, eq(adminTokens.userId, usersTable.id))
.where(eq(adminTokens.tokenHash, tokenHash))
.limit(1);
if (!row) {
throw new UnauthorizedException('Invalid API token');
}
if (row.expiresAt && row.expiresAt < new Date()) {
throw new UnauthorizedException('API token expired');
}
if (row.userRole !== 'admin') {
throw new ForbiddenException('Admin access required');
}
// Update last-used timestamp (fire-and-forget)
this.db
.update(adminTokens)
.set({ lastUsedAt: new Date() })
.where(eq(adminTokens.id, row.tokenId))
.then(() => {})
.catch(() => {});
const req = request as AuthenticatedRequest;
req.user = { id: row.userId, name: row.userName, email: row.userEmail, role: row.userRole };
req.session = { id: `token:${row.tokenId}`, userId: row.userId };
return true;
}
private async validateSession(request: FastifyRequest): Promise<boolean> {
const headers = fromNodeHeaders(request.raw.headers);
const result = await this.auth.api.getSession({ headers });
if (!result) {
@@ -38,8 +97,6 @@ export class AdminGuard implements CanActivate {
const user = result.user as UserWithRole;
// Ensure the role field is populated. better-auth should include additionalFields
// in the session, but as a fallback, fetch the role from the database if needed.
let userRole = user.role;
if (!userRole) {
const [dbUser] = await this.db
@@ -48,7 +105,6 @@ export class AdminGuard implements CanActivate {
.where(eq(usersTable.id, user.id))
.limit(1);
userRole = dbUser?.role ?? 'member';
// Update the session user object with the fetched role
(user as UserWithRole).role = userRole;
}
@@ -56,8 +112,9 @@ export class AdminGuard implements CanActivate {
throw new ForbiddenException('Admin access required');
}
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
const req = request as AuthenticatedRequest;
req.user = result.user;
req.session = result.session;
return true;
}

View File

@@ -2,10 +2,18 @@ import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller.js';
import { AdminHealthController } from './admin-health.controller.js';
import { AdminJobsController } from './admin-jobs.controller.js';
import { AdminTokensController } from './admin-tokens.controller.js';
import { BootstrapController } from './bootstrap.controller.js';
import { AdminGuard } from './admin.guard.js';
@Module({
controllers: [AdminController, AdminHealthController, AdminJobsController],
controllers: [
AdminController,
AdminHealthController,
AdminJobsController,
AdminTokensController,
BootstrapController,
],
providers: [AdminGuard],
})
export class AdminModule {}

View File

@@ -0,0 +1,101 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Inject,
InternalServerErrorException,
Post,
} from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto';
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaic/db';
import type { Auth } from '@mosaic/auth';
import { v4 as uuid } from 'uuid';
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
@Controller('api/bootstrap')
export class BootstrapController {
constructor(
@Inject(AUTH) private readonly auth: Auth,
@Inject(DB) private readonly db: Db,
) {}
@Get('status')
async status(): Promise<BootstrapStatusDto> {
const [result] = await this.db.select({ total: count() }).from(usersTable);
return { needsSetup: (result?.total ?? 0) === 0 };
}
@Post('setup')
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
// Only allow setup when zero users exist
const [result] = await this.db.select({ total: count() }).from(usersTable);
if ((result?.total ?? 0) > 0) {
throw new ForbiddenException('Setup already completed — users exist');
}
// Create admin user via BetterAuth API
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string };
}>;
};
const created = await authApi.createUser({
body: {
name: dto.name,
email: dto.email,
password: dto.password,
role: 'admin',
},
});
// Verify user was created
const [user] = await this.db
.select()
.from(usersTable)
.where(eq(usersTable.id, created.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found');
// Ensure role is admin (createUser may not set it via BetterAuth)
if (user.role !== 'admin') {
await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
}
// Generate admin API token
const plaintext = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const tokenId = uuid();
const [token] = await this.db
.insert(adminTokens)
.values({
id: tokenId,
userId: user.id,
tokenHash,
label: 'Initial setup token',
scope: 'admin',
})
.returning();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: 'admin',
},
token: {
id: token!.id,
plaintext,
label: token!.label,
},
};
}
}

View File

@@ -0,0 +1,31 @@
import { IsString, IsEmail, MinLength } from 'class-validator';
export class BootstrapSetupDto {
@IsString()
name!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}
export interface BootstrapStatusDto {
needsSetup: boolean;
}
export interface BootstrapResultDto {
user: {
id: string;
name: string;
email: string;
role: string;
};
token: {
id: string;
plaintext: string;
label: string;
};
}

View File

@@ -62,7 +62,7 @@ function restoreEnv(saved: Map<EnvKey, string | undefined>): void {
}
function makeRegistry(): ModelRegistry {
return new ModelRegistry(AuthStorage.inMemory());
return ModelRegistry.inMemory(AuthStorage.inMemory());
}
// ---------------------------------------------------------------------------

View File

@@ -67,7 +67,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> {
const authStorage = AuthStorage.inMemory();
this.registry = new ModelRegistry(authStorage);
this.registry = ModelRegistry.inMemory(authStorage);
// Build the default set of adapters that rely on the registry
this.adapters = [

View File

@@ -14,7 +14,7 @@ import { SsoController } from './sso.controller.js';
useFactory: (db: Db): Auth =>
createAuth({
db,
baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000',
baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242',
secret: process.env['BETTER_AUTH_SECRET'],
}),
inject: [DB],

View File

@@ -1,5 +1,8 @@
import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createDb, type Db, type DbHandle } from '@mosaic/db';
import { createDb, createPgliteDb, type Db, type DbHandle } from '@mosaic/db';
import { createStorageAdapter, type StorageAdapter } from '@mosaic/storage';
import type { MosaicConfig } from '@mosaic/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
@@ -13,8 +16,14 @@ export const STORAGE_ADAPTER = 'STORAGE_ADAPTER';
providers: [
{
provide: DB_HANDLE,
useFactory: (config: MosaicConfig): DbHandle =>
createDb(config.storage.type === 'postgres' ? config.storage.url : undefined),
useFactory: (config: MosaicConfig): DbHandle => {
if (config.tier === 'local') {
const dataDir = join(homedir(), '.config', 'mosaic', 'gateway', 'pglite');
mkdirSync(dataDir, { recursive: true });
return createPgliteDb(dataDir);
}
return createDb(config.storage.type === 'postgres' ? config.storage.url : undefined);
},
inject: [MOSAIC_CONFIG],
},
{

View File

@@ -1,5 +1,13 @@
#!/usr/bin/env node
import { config } from 'dotenv';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { homedir } from 'node:os';
// Load .env from daemon config dir (global install / daemon mode).
// Loaded first so monorepo .env can override for local dev.
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
if (existsSync(daemonEnv)) config({ path: daemonEnv });
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
config({ path: resolve(process.cwd(), '../../.env') });
@@ -51,7 +59,7 @@ async function bootstrap(): Promise<void> {
mountAuthHandler(app);
mountMcpHandler(app, app.get(McpService));
const port = Number(process.env['GATEWAY_PORT'] ?? 4000);
const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
await app.listen(port, '0.0.0.0');
logger.log(`Gateway listening on port ${port}`);
}

View File

@@ -48,7 +48,7 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
}
}
const DEFAULT_GATEWAY_URL = 'http://localhost:4000';
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = [];

View File

@@ -5,7 +5,7 @@ import { defineConfig, devices } from '@playwright/test';
*
* Assumes:
* - Next.js web app running on http://localhost:3000
* - NestJS gateway running on http://localhost:4000
* - NestJS gateway running on http://localhost:14242
*
* Run with: pnpm --filter @mosaic/web test:e2e
*/

View File

@@ -1,4 +1,4 @@
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000';
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
body?: unknown;

View File

@@ -2,7 +2,7 @@ import { createAuthClient } from 'better-auth/react';
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
plugins: [adminClient(), genericOAuthClient()],
});

View File

@@ -1,6 +1,6 @@
import { io, type Socket } from 'socket.io-client';
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000';
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
let socket: Socket | null = null;

View File

@@ -93,7 +93,7 @@ packages/cli/src/tui/
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
node packages/cli/dist/cli.js tui --gateway http://localhost:14242
```
### Quality Gates

View File

@@ -229,11 +229,11 @@ external clients. Authentication requires a valid BetterAuth session (cookie or
### Gateway
| Variable | Default | Description |
| --------------------- | ----------------------- | ---------------------------------------------- |
| `GATEWAY_PORT` | `4000` | Port the gateway listens on |
| `GATEWAY_CORS_ORIGIN` | `http://localhost:3000` | Allowed CORS origin for browser clients |
| `BETTER_AUTH_URL` | `http://localhost:4000` | Public URL of the gateway (used by BetterAuth) |
| Variable | Default | Description |
| --------------------- | ------------------------ | ---------------------------------------------- |
| `GATEWAY_PORT` | `14242` | Port the gateway listens on |
| `GATEWAY_CORS_ORIGIN` | `http://localhost:3000` | Allowed CORS origin for browser clients |
| `BETTER_AUTH_URL` | `http://localhost:14242` | Public URL of the gateway (used by BetterAuth) |
### SSO (Optional)
@@ -292,13 +292,13 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
### Plugins
| Variable | Description |
| ---------------------- | ------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
| `DISCORD_GUILD_ID` | Discord guild/server ID |
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:4000`) |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
| Variable | Description |
| ---------------------- | -------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
| `DISCORD_GUILD_ID` | Discord guild/server ID |
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
### Observability
@@ -309,9 +309,9 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
### Web App
| Variable | Default | Description |
| ------------------------- | ----------------------- | -------------------------------------- |
| `NEXT_PUBLIC_GATEWAY_URL` | `http://localhost:4000` | Gateway URL used by the Next.js client |
| Variable | Default | Description |
| ------------------------- | ------------------------ | -------------------------------------- |
| `NEXT_PUBLIC_GATEWAY_URL` | `http://localhost:14242` | Gateway URL used by the Next.js client |
### Coordination

View File

@@ -194,7 +194,7 @@ server {
# WebSocket support (for chat.gateway.ts / Socket.IO)
location /socket.io/ {
proxy_pass http://127.0.0.1:4000;
proxy_pass http://127.0.0.1:14242;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -204,7 +204,7 @@ server {
# REST + auth
location / {
proxy_pass http://127.0.0.1:4000;
proxy_pass http://127.0.0.1:14242;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -234,11 +234,11 @@ server {
# /etc/caddy/Caddyfile
your-domain.example.com {
reverse_proxy /socket.io/* localhost:4000 {
reverse_proxy /socket.io/* localhost:14242 {
header_up Upgrade {http.upgrade}
header_up Connection {http.connection}
}
reverse_proxy localhost:4000
reverse_proxy localhost:14242
}
app.your-domain.example.com {
@@ -328,7 +328,7 @@ MaxRetentionSec=30day
- Set `BETTER_AUTH_SECRET` to a cryptographically random value (`openssl rand -base64 32`).
- Restrict `GATEWAY_CORS_ORIGIN` to your exact frontend origin — do not use `*`.
- Run services as a dedicated non-root system user (e.g., `mosaic`).
- Firewall: only expose ports 80/443 externally; keep 4000 and 3000 bound to `127.0.0.1`.
- Firewall: only expose ports 80/443 externally; keep 14242 and 3000 bound to `127.0.0.1`.
- Set `AGENT_FILE_SANDBOX_DIR` to a directory outside the application root to prevent agent tools from accessing source code.
- If using `AGENT_USER_TOOLS`, enumerate only the tools non-admin users need.

View File

@@ -112,11 +112,11 @@ DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
BETTER_AUTH_SECRET=change-me-to-a-random-secret
# Gateway
GATEWAY_PORT=4000
GATEWAY_PORT=14242
GATEWAY_CORS_ORIGIN=http://localhost:3000
# Web
NEXT_PUBLIC_GATEWAY_URL=http://localhost:4000
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
# Optional: Ollama
OLLAMA_BASE_URL=http://localhost:11434
@@ -141,7 +141,7 @@ migrations in production).
pnpm --filter @mosaic/gateway exec tsx src/main.ts
```
The gateway starts on port `4000` by default.
The gateway starts on port `14242` by default.
### 6. Start the Web App
@@ -395,7 +395,7 @@ directory are defined there.
## API Endpoint Reference
All endpoints are served by the gateway at `http://localhost:4000` by default.
All endpoints are served by the gateway at `http://localhost:14242` by default.
### Authentication

View File

@@ -16,7 +16,7 @@
### Prerequisites
Mosaic Stack requires a running gateway. Your administrator provides the URL
(default: `http://localhost:4000`) and creates your account.
(default: `http://localhost:14242`) and creates your account.
### Logging In (Web)
@@ -177,7 +177,7 @@ mosaic --help
### Signing In
```bash
mosaic login --gateway http://localhost:4000 --email you@example.com
mosaic login --gateway http://localhost:14242 --email you@example.com
```
You are prompted for a password if `--password` is not supplied. The session
@@ -191,12 +191,12 @@ mosaic tui
Options:
| Flag | Default | Description |
| ----------------------- | ----------------------- | ---------------------------------- |
| `--gateway <url>` | `http://localhost:4000` | Gateway URL |
| `--conversation <id>` | — | Resume a specific conversation |
| `--model <modelId>` | server default | Model to use (e.g. `llama3.2`) |
| `--provider <provider>` | server default | Provider (e.g. `ollama`, `openai`) |
| Flag | Default | Description |
| ----------------------- | ------------------------ | ---------------------------------- |
| `--gateway <url>` | `http://localhost:14242` | Gateway URL |
| `--conversation <id>` | — | Resume a specific conversation |
| `--model <modelId>` | server default | Model to use (e.g. `llama3.2`) |
| `--provider <provider>` | server default | Provider (e.g. `ollama`, `openai`) |
If no valid session exists you are prompted to sign in before the TUI launches.

View File

@@ -1,6 +1,6 @@
{
"tier": "local",
"storage": { "type": "sqlite", "path": ".mosaic/data.db" },
"storage": { "type": "pglite", "dataDir": ".mosaic/storage-pglite" },
"queue": { "type": "local", "dataDir": ".mosaic/queue" },
"memory": { "type": "keyword" }
}

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/agent",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/agent"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/auth",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/auth"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -35,7 +35,7 @@ export function createAuth(config: AuthConfig) {
provider: 'pg',
usePlural: true,
}),
baseURL: baseURL ?? process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000',
baseURL: baseURL ?? process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242',
secret: secret ?? process.env['BETTER_AUTH_SECRET'],
basePath: '/api/auth',
trustedOrigins,

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/brain",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/brain"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/cli",
"version": "0.0.3",
"version": "0.0.16",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/cli"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -2,7 +2,7 @@
import { createRequire } from 'module';
import { Command } from 'commander';
import { createQualityRailsCli } from '@mosaic/quality-rails';
import { registerQualityRails } from '@mosaic/quality-rails';
import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js';
// prdy is registered via launch.ts
@@ -33,7 +33,7 @@ registerLaunchCommands(program);
program
.command('login')
.description('Sign in to a Mosaic gateway')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('-e, --email <email>', 'Email address')
.option('-p, --password <password>', 'Password')
.action(async (opts: { gateway: string; email?: string; password?: string }) => {
@@ -67,7 +67,7 @@ program
program
.command('tui')
.description('Launch interactive TUI connected to the gateway')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('-c, --conversation <id>', 'Resume a conversation by ID')
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
@@ -208,7 +208,7 @@ const sessionsCmd = program.command('sessions').description('Manage active agent
sessionsCmd
.command('list')
.description('List active agent sessions')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (opts: { gateway: string }) => {
const { withAuth } = await import('./commands/with-auth.js');
const auth = await withAuth(opts.gateway);
@@ -243,7 +243,7 @@ sessionsCmd
sessionsCmd
.command('resume <id>')
.description('Resume an existing agent session in the TUI')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (id: string, opts: { gateway: string }) => {
const { loadSession, validateSession } = await import('./auth.js');
@@ -276,7 +276,7 @@ sessionsCmd
sessionsCmd
.command('destroy <id>')
.description('Terminate an active agent session')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (id: string, opts: { gateway: string }) => {
const { withAuth } = await import('./commands/with-auth.js');
const auth = await withAuth(opts.gateway);
@@ -305,11 +305,7 @@ registerMissionCommand(program);
// ─── quality-rails ──────────────────────────────────────────────────────
const qrWrapper = createQualityRailsCli();
const qrCmd = qrWrapper.commands.find((c) => c.name() === 'quality-rails');
if (qrCmd !== undefined) {
program.addCommand(qrCmd as unknown as Command);
}
registerQualityRails(program);
// ─── update ─────────────────────────────────────────────────────────────

View File

@@ -34,7 +34,7 @@ export function registerAgentCommand(program: Command) {
const cmd = program
.command('agent')
.description('Manage agent configurations')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--list', 'List all agents')
.option('--new', 'Create a new agent')
.option('--show <idOrName>', 'Show agent details')

View File

@@ -1,198 +1,152 @@
import { createInterface } from 'node:readline';
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import type { Command } from 'commander';
import {
DEFAULT_LOCAL_CONFIG,
DEFAULT_TEAM_CONFIG,
loadConfig,
type MosaicConfig,
type StorageTier,
} from '@mosaic/config';
getDaemonPid,
readMeta,
startDaemon,
stopDaemon,
waitForHealth,
} from './gateway/daemon.js';
function ask(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((res) => rl.question(question, res));
interface GatewayParentOpts {
host: string;
port: string;
token?: string;
}
async function runInit(opts: { tier?: string; output: string }): Promise<void> {
const outputPath = resolve(opts.output);
let tier: StorageTier;
if (opts.tier) {
if (opts.tier !== 'local' && opts.tier !== 'team') {
console.error(`Invalid tier "${opts.tier}" — expected "local" or "team"`);
process.exit(1);
}
tier = opts.tier;
} else {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await ask(rl, 'Select tier (local/team) [local]: ');
rl.close();
const trimmed = answer.trim().toLowerCase();
tier = trimmed === 'team' ? 'team' : 'local';
}
let config: MosaicConfig;
if (tier === 'local') {
config = DEFAULT_LOCAL_CONFIG;
} else {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const dbUrl = await ask(
rl,
'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5432/mosaic]: ',
);
const valkeyUrl = await ask(rl, 'VALKEY_URL [redis://localhost:6379]: ');
rl.close();
config = {
...DEFAULT_TEAM_CONFIG,
storage: {
type: 'postgres',
url: dbUrl.trim() || 'postgresql://mosaic:mosaic@localhost:5432/mosaic',
},
queue: {
type: 'bullmq',
url: valkeyUrl.trim() || 'redis://localhost:6379',
},
};
}
writeFileSync(outputPath, JSON.stringify(config, null, 2) + '\n');
console.log(`\nWrote ${outputPath}`);
console.log('\nNext steps:');
console.log(' 1. Review the generated config');
console.log(' 2. Run: pnpm --filter @mosaic/gateway exec tsx src/main.ts');
}
const PID_FILE = resolve(process.cwd(), '.mosaic/gateway.pid');
function writePidFile(pid: number): void {
const dir = dirname(PID_FILE);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(PID_FILE, String(pid));
}
function readPidFile(): number | null {
if (!existsSync(PID_FILE)) return null;
const raw = readFileSync(PID_FILE, 'utf-8').trim();
const pid = Number(raw);
return Number.isFinite(pid) ? pid : null;
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function printConfigSummary(config: MosaicConfig): void {
console.log(` Tier: ${config.tier}`);
console.log(` Storage: ${config.storage.type}`);
console.log(` Queue: ${config.queue.type}`);
console.log(` Memory: ${config.memory.type}`);
function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; token?: string } {
const meta = readMeta();
return {
host: raw.host ?? meta?.host ?? 'localhost',
port: parseInt(raw.port, 10) || meta?.port || 14242,
token: raw.token ?? meta?.adminToken,
};
}
export function registerGatewayCommand(program: Command): void {
const gateway = program.command('gateway').description('Gateway management commands');
gateway
.command('init')
.description('Generate a mosaic.config.json for the gateway')
.option('--tier <tier>', 'Storage tier: local or team (skips interactive prompt)')
.option('--output <path>', 'Output file path', './mosaic.config.json')
.action(async (opts: { tier?: string; output: string }) => {
await runInit(opts);
});
gateway
.command('start')
.description('Start the Mosaic gateway process')
.option('--port <port>', 'Port to listen on (overrides config)')
.option('--daemon', 'Run in background and write PID to .mosaic/gateway.pid')
.action((opts: { port?: string; daemon?: boolean }) => {
const config = loadConfig();
const port = opts.port ?? '4000';
console.log('Starting gateway…');
printConfigSummary(config);
console.log(` Port: ${port}`);
const entryPoint = resolve(process.cwd(), 'apps/gateway/src/main.ts');
const env = { ...process.env, GATEWAY_PORT: port };
if (opts.daemon) {
const child = spawn('npx', ['tsx', entryPoint], {
env,
stdio: 'ignore',
detached: true,
});
child.unref();
if (child.pid) {
writePidFile(child.pid);
console.log(`\nGateway started in background (PID ${child.pid})`);
console.log(`PID file: ${PID_FILE}`);
}
} else {
const child = spawn('npx', ['tsx', entryPoint], {
env,
stdio: 'inherit',
});
child.on('exit', (code) => {
process.exit(code ?? 0);
});
}
});
gateway
.command('stop')
.description('Stop the running gateway process')
const gw = program
.command('gateway')
.description('Manage the Mosaic gateway daemon')
.helpOption('--help', 'Display help')
.option('-h, --host <host>', 'Gateway host', 'localhost')
.option('-p, --port <port>', 'Gateway port', '14242')
.option('-t, --token <token>', 'Admin API token')
.action(() => {
const pid = readPidFile();
gw.outputHelp();
});
if (pid === null) {
console.error('No PID file found at', PID_FILE);
// ─── install ────────────────────────────────────────────────────────────
gw.command('install')
.description('Install and configure the gateway daemon')
.option('--skip-install', 'Skip npm package installation (use local build)')
.action(async (cmdOpts: { skipInstall?: boolean }) => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const { runInstall } = await import('./gateway/install.js');
await runInstall({ ...opts, skipInstall: cmdOpts.skipInstall });
});
// ─── start ──────────────────────────────────────────────────────────────
gw.command('start')
.description('Start the gateway daemon')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
try {
const pid = startDaemon();
console.log(`Gateway started (PID ${pid.toString()})`);
console.log('Waiting for health...');
const healthy = await waitForHealth(opts.host, opts.port);
if (healthy) {
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
} else {
console.warn('Gateway started but health check timed out. Check logs.');
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
if (!isProcessRunning(pid)) {
console.log(`Process ${pid} is not running. Removing stale PID file.`);
unlinkSync(PID_FILE);
return;
}
process.kill(pid, 'SIGTERM');
unlinkSync(PID_FILE);
console.log(`Gateway stopped (PID ${pid})`);
});
gateway
.command('status')
.description('Show gateway process status')
.action(() => {
const config = loadConfig();
const pid = readPidFile();
// ─── stop ───────────────────────────────────────────────────────────────
if (pid !== null && isProcessRunning(pid)) {
console.log('Gateway: running');
console.log(` PID: ${pid}`);
} else {
console.log('Gateway: stopped');
if (pid !== null) {
console.log(` (stale PID file for ${pid})`);
unlinkSync(PID_FILE);
}
gw.command('stop')
.description('Stop the gateway daemon')
.action(async () => {
try {
await stopDaemon();
console.log('Gateway stopped.');
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
console.log('');
console.log('Config:');
printConfigSummary(config);
// ─── restart ────────────────────────────────────────────────────────────
gw.command('restart')
.description('Restart the gateway daemon')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const pid = getDaemonPid();
if (pid !== null) {
console.log('Stopping gateway...');
await stopDaemon();
}
console.log('Starting gateway...');
try {
const newPid = startDaemon();
console.log(`Gateway started (PID ${newPid.toString()})`);
const healthy = await waitForHealth(opts.host, opts.port);
if (healthy) {
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
} else {
console.warn('Gateway started but health check timed out. Check logs.');
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── status ─────────────────────────────────────────────────────────────
gw.command('status')
.description('Show gateway daemon status and health')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const { runStatus } = await import('./gateway/status.js');
await runStatus(opts);
});
// ─── config ─────────────────────────────────────────────────────────────
gw.command('config')
.description('View or modify gateway configuration')
.option('--set <KEY=VALUE>', 'Set a configuration value')
.option('--unset <KEY>', 'Remove a configuration key')
.option('--edit', 'Open config in $EDITOR')
.action(async (cmdOpts: { set?: string; unset?: string; edit?: boolean }) => {
const { runConfig } = await import('./gateway/config.js');
await runConfig(cmdOpts);
});
// ─── logs ───────────────────────────────────────────────────────────────
gw.command('logs')
.description('View gateway daemon logs')
.option('-f, --follow', 'Follow log output')
.option('-n, --lines <count>', 'Number of lines to show', '50')
.action(async (cmdOpts: { follow?: boolean; lines?: string }) => {
const { runLogs } = await import('./gateway/logs.js');
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
});
// ─── uninstall ──────────────────────────────────────────────────────────
gw.command('uninstall')
.description('Uninstall the gateway daemon and optionally remove data')
.action(async () => {
const { runUninstall } = await import('./gateway/uninstall.js');
await runUninstall();
});
}

View File

@@ -0,0 +1,143 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { ENV_FILE, getDaemonPid, readMeta, META_FILE, ensureDirs } from './daemon.js';
// Keys that should be masked in output
const SECRET_KEYS = new Set([
'BETTER_AUTH_SECRET',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'ZAI_API_KEY',
'OPENROUTER_API_KEY',
'DISCORD_BOT_TOKEN',
'TELEGRAM_BOT_TOKEN',
]);
function maskValue(key: string, value: string): string {
if (SECRET_KEYS.has(key) && value.length > 8) {
return value.slice(0, 4) + '…' + value.slice(-4);
}
return value;
}
function parseEnvFile(): Map<string, string> {
const map = new Map<string, string>();
if (!existsSync(ENV_FILE)) return map;
const lines = readFileSync(ENV_FILE, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
map.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
}
return map;
}
function writeEnvFile(entries: Map<string, string>): void {
ensureDirs();
const lines: string[] = [];
for (const [key, value] of entries) {
lines.push(`${key}=${value}`);
}
writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
}
interface ConfigOpts {
set?: string;
unset?: string;
edit?: boolean;
}
export async function runConfig(opts: ConfigOpts): Promise<void> {
// Set a value
if (opts.set) {
const eqIdx = opts.set.indexOf('=');
if (eqIdx === -1) {
console.error('Usage: mosaic gateway config --set KEY=VALUE');
process.exit(1);
}
const key = opts.set.slice(0, eqIdx);
const value = opts.set.slice(eqIdx + 1);
const entries = parseEnvFile();
entries.set(key, value);
writeEnvFile(entries);
console.log(`Set ${key}=${maskValue(key, value)}`);
promptRestart();
return;
}
// Unset a value
if (opts.unset) {
const entries = parseEnvFile();
if (!entries.has(opts.unset)) {
console.error(`Key not found: ${opts.unset}`);
process.exit(1);
}
entries.delete(opts.unset);
writeEnvFile(entries);
console.log(`Removed ${opts.unset}`);
promptRestart();
return;
}
// Open in editor
if (opts.edit) {
if (!existsSync(ENV_FILE)) {
console.error(`No config file found at ${ENV_FILE}`);
console.error('Run `mosaic gateway install` first.');
process.exit(1);
}
const editor = process.env['EDITOR'] ?? process.env['VISUAL'] ?? 'vi';
try {
execSync(`${editor} "${ENV_FILE}"`, { stdio: 'inherit' });
promptRestart();
} catch {
console.error('Editor exited with error.');
}
return;
}
// Default: show current config
showConfig();
}
function showConfig(): void {
if (!existsSync(ENV_FILE)) {
console.log('No gateway configuration found.');
console.log('Run `mosaic gateway install` to set up.');
return;
}
const entries = parseEnvFile();
const meta = readMeta();
console.log('Mosaic Gateway Configuration');
console.log('────────────────────────────');
console.log(` Config file: ${ENV_FILE}`);
console.log(` Meta file: ${META_FILE}`);
console.log();
if (entries.size === 0) {
console.log(' (empty)');
return;
}
const maxKeyLen = Math.max(...[...entries.keys()].map((k) => k.length));
for (const [key, value] of entries) {
const padding = ' '.repeat(maxKeyLen - key.length);
console.log(` ${key}${padding} ${maskValue(key, value)}`);
}
if (meta?.adminToken) {
console.log();
console.log(` Admin token: ${maskValue('token', meta.adminToken)}`);
}
}
function promptRestart(): void {
if (getDaemonPid() !== null) {
console.log('\nGateway is running — restart to apply changes: mosaic gateway restart');
}
}

View File

@@ -0,0 +1,245 @@
import { spawn, execSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
openSync,
constants,
} from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { createRequire } from 'node:module';
// ─── Paths ──────────────────────────────────────────────────────────────────
export const GATEWAY_HOME = resolve(
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
);
export const PID_FILE = join(GATEWAY_HOME, 'daemon.pid');
export const LOG_DIR = join(GATEWAY_HOME, 'logs');
export const LOG_FILE = join(LOG_DIR, 'gateway.log');
export const ENV_FILE = join(GATEWAY_HOME, '.env');
export const META_FILE = join(GATEWAY_HOME, 'meta.json');
// ─── Meta ───────────────────────────────────────────────────────────────────
export interface GatewayMeta {
version: string;
installedAt: string;
entryPoint: string;
adminToken?: string;
host: string;
port: number;
}
export function readMeta(): GatewayMeta | null {
if (!existsSync(META_FILE)) return null;
try {
return JSON.parse(readFileSync(META_FILE, 'utf-8')) as GatewayMeta;
} catch {
return null;
}
}
export function writeMeta(meta: GatewayMeta): void {
ensureDirs();
writeFileSync(META_FILE, JSON.stringify(meta, null, 2), { mode: 0o600 });
}
// ─── Directories ────────────────────────────────────────────────────────────
export function ensureDirs(): void {
mkdirSync(GATEWAY_HOME, { recursive: true, mode: 0o700 });
mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
}
// ─── PID management ─────────────────────────────────────────────────────────
export function readPid(): number | null {
if (!existsSync(PID_FILE)) return null;
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
export function isRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function getDaemonPid(): number | null {
const pid = readPid();
if (pid === null) return null;
return isRunning(pid) ? pid : null;
}
// ─── Entry point resolution ─────────────────────────────────────────────────
export function resolveGatewayEntry(): string {
// Check meta.json for custom entry point
const meta = readMeta();
if (meta?.entryPoint && existsSync(meta.entryPoint)) {
return meta.entryPoint;
}
// Try to resolve from globally installed @mosaic/gateway
try {
const req = createRequire(import.meta.url);
const pkgPath = req.resolve('@mosaic/gateway/package.json');
const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js');
if (existsSync(mainEntry)) return mainEntry;
} catch {
// Not installed globally
}
throw new Error('Cannot find gateway entry point. Run `mosaic gateway install` first.');
}
// ─── Start / Stop / Health ──────────────────────────────────────────────────
export function startDaemon(): number {
const running = getDaemonPid();
if (running !== null) {
throw new Error(`Gateway is already running (PID ${running.toString()})`);
}
ensureDirs();
const entryPoint = resolveGatewayEntry();
// Load env vars from gateway .env
const env: Record<string, string> = { ...process.env } as Record<string, string>;
if (existsSync(ENV_FILE)) {
for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx > 0) env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
}
}
const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND);
const child = spawn('node', [entryPoint], {
detached: true,
stdio: ['ignore', logFd, logFd],
env,
cwd: GATEWAY_HOME,
});
if (!child.pid) {
throw new Error('Failed to spawn gateway process');
}
writeFileSync(PID_FILE, child.pid.toString(), { mode: 0o600 });
child.unref();
return child.pid;
}
export async function stopDaemon(timeoutMs = 10_000): Promise<void> {
const pid = getDaemonPid();
if (pid === null) {
throw new Error('Gateway is not running');
}
process.kill(pid, 'SIGTERM');
// Poll for exit
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!isRunning(pid)) {
cleanPidFile();
return;
}
await sleep(250);
}
// Force kill
try {
process.kill(pid, 'SIGKILL');
} catch {
// Already dead
}
cleanPidFile();
}
function cleanPidFile(): void {
try {
unlinkSync(PID_FILE);
} catch {
// Ignore
}
}
export async function waitForHealth(
host: string,
port: number,
timeoutMs = 30_000,
): Promise<boolean> {
const start = Date.now();
let delay = 500;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(`http://${host}:${port.toString()}/health`);
if (res.ok) return true;
} catch {
// Not ready yet
}
await sleep(delay);
delay = Math.min(delay * 1.5, 3000);
}
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── npm install helper ─────────────────────────────────────────────────────
const GITEA_REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
export function installGatewayPackage(): void {
console.log('Installing @mosaic/gateway from Gitea registry...');
execSync(`npm install -g @mosaic/gateway@latest --@mosaic:registry=${GITEA_REGISTRY}`, {
stdio: 'inherit',
timeout: 120_000,
});
}
export function uninstallGatewayPackage(): void {
try {
execSync('npm uninstall -g @mosaic/gateway', {
stdio: 'inherit',
timeout: 60_000,
});
} catch {
console.warn('Warning: npm uninstall may not have completed cleanly.');
}
}
export function getInstalledGatewayVersion(): string | null {
try {
const output = execSync('npm ls -g @mosaic/gateway --json --depth=0', {
encoding: 'utf-8',
timeout: 15_000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const data = JSON.parse(output) as {
dependencies?: { '@mosaic/gateway'?: { version?: string } };
};
return data.dependencies?.['@mosaic/gateway']?.version ?? null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,259 @@
import { randomBytes } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
import type { GatewayMeta } from './daemon.js';
import {
ENV_FILE,
GATEWAY_HOME,
ensureDirs,
installGatewayPackage,
readMeta,
resolveGatewayEntry,
startDaemon,
waitForHealth,
writeMeta,
getInstalledGatewayVersion,
} from './daemon.js';
interface InstallOpts {
host: string;
port: number;
skipInstall?: boolean;
}
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
export async function runInstall(opts: InstallOpts): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
await doInstall(rl, opts);
} finally {
rl.close();
}
}
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
// Check existing installation
const existing = readMeta();
if (existing) {
const answer = await prompt(
rl,
`Gateway already installed (v${existing.version}). Reinstall? [y/N] `,
);
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
}
}
// Step 1: Install npm package
if (!opts.skipInstall) {
installGatewayPackage();
}
ensureDirs();
// Step 2: Collect configuration
console.log('\n─── Gateway Configuration ───\n');
// Tier selection
console.log('Storage tier:');
console.log(' 1. Local (embedded database, no dependencies)');
console.log(' 2. Team (PostgreSQL + Valkey required)');
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
const tier = tierAnswer === '2' ? 'team' : 'local';
const port =
opts.port !== 14242
? opts.port
: parseInt(
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
10,
);
let databaseUrl: string | undefined;
let valkeyUrl: string | undefined;
if (tier === 'team') {
databaseUrl =
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
valkeyUrl =
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
}
const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
const corsOrigin =
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
// Generate auth secret
const authSecret = randomBytes(32).toString('hex');
// Step 3: Write .env
const envLines = [
`GATEWAY_PORT=${port.toString()}`,
`BETTER_AUTH_SECRET=${authSecret}`,
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
`OTEL_SERVICE_NAME=mosaic-gateway`,
];
if (tier === 'team' && databaseUrl && valkeyUrl) {
envLines.push(`DATABASE_URL=${databaseUrl}`);
envLines.push(`VALKEY_URL=${valkeyUrl}`);
}
if (anthropicKey) {
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
}
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
console.log(`\nConfig written to ${ENV_FILE}`);
// Step 3b: Write mosaic.config.json
const mosaicConfig =
tier === 'local'
? {
tier: 'local',
storage: { type: 'pglite', dataDir: join(GATEWAY_HOME, 'storage-pglite') },
queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') },
memory: { type: 'keyword' },
}
: {
tier: 'team',
storage: { type: 'postgres', url: databaseUrl },
queue: { type: 'bullmq', url: valkeyUrl },
memory: { type: 'pgvector' },
};
const configFile = join(GATEWAY_HOME, 'mosaic.config.json');
writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
console.log(`Config written to ${configFile}`);
// Step 4: Write meta.json
let entryPoint: string;
try {
entryPoint = resolveGatewayEntry();
} catch {
console.error('Error: Gateway package not found after install.');
console.error('Check that @mosaic/gateway installed correctly.');
return;
}
const version = getInstalledGatewayVersion() ?? 'unknown';
const meta = {
version,
installedAt: new Date().toISOString(),
entryPoint,
host: opts.host,
port,
};
writeMeta(meta);
// Step 5: Start the daemon
console.log('\nStarting gateway daemon...');
try {
const pid = startDaemon();
console.log(`Gateway started (PID ${pid.toString()})`);
} catch (err) {
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
return;
}
// Step 6: Wait for health
console.log('Waiting for gateway to become healthy...');
const healthy = await waitForHealth(opts.host, port, 30_000);
if (!healthy) {
console.error('Gateway did not become healthy within 30 seconds.');
console.error(`Check logs: mosaic gateway logs`);
return;
}
console.log('Gateway is healthy.\n');
// Step 7: Bootstrap — first user setup
await bootstrapFirstUser(rl, opts.host, port, meta);
console.log('\n─── Installation Complete ───');
console.log(` Endpoint: http://${opts.host}:${port.toString()}`);
console.log(` Config: ${GATEWAY_HOME}`);
console.log(` Logs: mosaic gateway logs`);
console.log(` Status: mosaic gateway status`);
}
async function bootstrapFirstUser(
rl: ReturnType<typeof createInterface>,
host: string,
port: number,
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string },
): Promise<void> {
const baseUrl = `http://${host}:${port.toString()}`;
try {
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
if (!statusRes.ok) return;
const status = (await statusRes.json()) as { needsSetup: boolean };
if (!status.needsSetup) {
console.log('Admin user already exists — skipping setup.');
return;
}
} catch {
console.warn('Could not check bootstrap status — skipping first user setup.');
return;
}
console.log('─── Admin User Setup ───\n');
const name = (await prompt(rl, 'Admin name: ')).trim();
if (!name) {
console.error('Name is required.');
return;
}
const email = (await prompt(rl, 'Admin email: ')).trim();
if (!email) {
console.error('Email is required.');
return;
}
const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim();
if (password.length < 8) {
console.error('Password must be at least 8 characters.');
return;
}
try {
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
console.error(`Bootstrap failed (${res.status.toString()}): ${body}`);
return;
}
const result = (await res.json()) as {
user: { id: string; email: string };
token: { plaintext: string };
};
// Save admin token to meta
meta.adminToken = result.token.plaintext;
writeMeta(meta as GatewayMeta);
console.log(`\nAdmin user created: ${result.user.email}`);
console.log('Admin API token saved to gateway config.');
} catch (err) {
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
}
}

View File

@@ -0,0 +1,37 @@
import { existsSync, readFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { LOG_FILE } from './daemon.js';
interface LogsOpts {
follow?: boolean;
lines?: number;
}
export function runLogs(opts: LogsOpts): void {
if (!existsSync(LOG_FILE)) {
console.log('No log file found. Is the gateway installed?');
return;
}
if (opts.follow) {
const lines = opts.lines ?? 50;
const tail = spawn('tail', ['-n', lines.toString(), '-f', LOG_FILE], {
stdio: 'inherit',
});
tail.on('error', () => {
// Fallback for systems without tail
console.log(readLastLines(opts.lines ?? 50));
console.log('\n(--follow requires `tail` command)');
});
return;
}
// Just print last N lines
console.log(readLastLines(opts.lines ?? 50));
}
function readLastLines(n: number): string {
const content = readFileSync(LOG_FILE, 'utf-8');
const lines = content.split('\n');
return lines.slice(-n).join('\n');
}

View File

@@ -0,0 +1,115 @@
import { getDaemonPid, readMeta, LOG_FILE, GATEWAY_HOME } from './daemon.js';
interface GatewayOpts {
host: string;
port: number;
token?: string;
}
interface ServiceStatus {
name: string;
status: string;
latency?: string;
}
interface AdminHealth {
status: string;
services: {
database: { status: string; latencyMs: number };
cache: { status: string; latencyMs: number };
};
agentPool?: { active: number };
providers?: Array<{ name: string; available: boolean; models: number }>;
}
export async function runStatus(opts: GatewayOpts): Promise<void> {
const meta = readMeta();
const pid = getDaemonPid();
console.log('Mosaic Gateway Status');
console.log('─────────────────────');
// Daemon status
if (pid !== null) {
console.log(` Status: running (PID ${pid.toString()})`);
} else {
console.log(' Status: stopped');
}
// Version
console.log(` Version: ${meta?.version ?? 'unknown'}`);
// Endpoint
const host = opts.host;
const port = opts.port;
console.log(` Endpoint: http://${host}:${port.toString()}`);
console.log(` Config: ${GATEWAY_HOME}`);
console.log(` Logs: ${LOG_FILE}`);
if (pid === null) return;
// Health check
try {
const healthRes = await fetch(`http://${host}:${port.toString()}/health`);
if (!healthRes.ok) {
console.log('\n Health: unreachable');
return;
}
} catch {
console.log('\n Health: unreachable');
return;
}
// Admin health (requires token)
const token = opts.token ?? meta?.adminToken;
if (!token) {
console.log(
'\n (No admin token — run `mosaic gateway config` to set one for detailed status)',
);
return;
}
try {
const res = await fetch(`http://${host}:${port.toString()}/api/admin/health`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
console.log('\n Admin health: unauthorized or unavailable');
return;
}
const health = (await res.json()) as AdminHealth;
console.log('\n Services:');
const services: ServiceStatus[] = [
{
name: 'Database',
status: health.services.database.status,
latency: `${health.services.database.latencyMs.toString()}ms`,
},
{
name: 'Cache',
status: health.services.cache.status,
latency: `${health.services.cache.latencyMs.toString()}ms`,
},
];
for (const svc of services) {
const latStr = svc.latency ? ` (${svc.latency})` : '';
console.log(` ${svc.name}:${' '.repeat(10 - svc.name.length)}${svc.status}${latStr}`);
}
if (health.providers && health.providers.length > 0) {
const available = health.providers.filter((p) => p.available);
const names = available.map((p) => p.name).join(', ');
console.log(`\n Providers: ${available.length.toString()} active (${names})`);
}
if (health.agentPool) {
console.log(` Sessions: ${health.agentPool.active.toString()} active`);
}
} catch {
console.log('\n Admin health: connection error');
}
}

View File

@@ -0,0 +1,62 @@
import { existsSync, rmSync } from 'node:fs';
import { createInterface } from 'node:readline';
import {
GATEWAY_HOME,
getDaemonPid,
readMeta,
stopDaemon,
uninstallGatewayPackage,
} from './daemon.js';
export async function runUninstall(): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
await doUninstall(rl);
} finally {
rl.close();
}
}
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
const meta = readMeta();
if (!meta) {
console.log('Gateway is not installed.');
return;
}
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
}
// Stop if running
if (getDaemonPid() !== null) {
console.log('Stopping gateway daemon...');
try {
await stopDaemon();
console.log('Stopped.');
} catch (err) {
console.warn(`Warning: ${err instanceof Error ? err.message : String(err)}`);
}
}
// Remove config/data
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
if (removeData.toLowerCase() === 'y') {
if (existsSync(GATEWAY_HOME)) {
rmSync(GATEWAY_HOME, { recursive: true, force: true });
console.log('Gateway data removed.');
}
}
// Uninstall npm package
console.log('Uninstalling npm package...');
uninstallGatewayPackage();
console.log('\nGateway uninstalled.');
}

View File

@@ -7,6 +7,7 @@
import { execFileSync, execSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
@@ -67,7 +68,7 @@ function checkSoul(): void {
}
// Fallback: legacy bash mosaic-init
const initBin = join(MOSAIC_HOME, 'tools', '_scripts', 'mosaic-init');
const initBin = fwScript('mosaic-init');
if (existsSync(initBin)) {
spawnSync(initBin, [], { stdio: 'inherit' });
} else {
@@ -78,7 +79,7 @@ function checkSoul(): void {
}
function checkSequentialThinking(runtime: string): void {
const checker = join(MOSAIC_HOME, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
const checker = fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return; // Skip if checker doesn't exist
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
if (result.status !== 0) {
@@ -491,12 +492,29 @@ function delegateToScript(scriptPath: string, args: string[], env?: Record<strin
}
}
/**
* Resolve a path under the framework tools directory. Prefers the version
* bundled in the @mosaic/mosaic npm package (always matches the installed
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
*/
function resolveTool(...segments: string[]): string {
try {
const req = createRequire(import.meta.url);
const mosaicPkg = dirname(req.resolve('@mosaic/mosaic/package.json'));
const bundled = join(mosaicPkg, 'framework', 'tools', ...segments);
if (existsSync(bundled)) return bundled;
} catch {
// Fall through to deployed copy
}
return join(MOSAIC_HOME, 'tools', ...segments);
}
function fwScript(name: string): string {
return join(MOSAIC_HOME, 'tools', '_scripts', name);
return resolveTool('_scripts', name);
}
function toolScript(toolDir: string, name: string): string {
return join(MOSAIC_HOME, 'tools', toolDir, name);
return resolveTool(toolDir, name);
}
// ─── Coord (mission orchestrator) ───────────────────────────────────────────

View File

@@ -40,7 +40,7 @@ export function registerMissionCommand(program: Command) {
const cmd = program
.command('mission')
.description('Manage missions')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--list', 'List all missions')
.option('--init', 'Create a new mission')
.option('--plan <idOrName>', 'Run PRD wizard for a mission')
@@ -86,7 +86,7 @@ export function registerMissionCommand(program: Command) {
cmd
.command('task')
.description('Manage mission tasks')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--list', 'List tasks for a mission')
.option('--new', 'Create a task')
.option('--update <taskId>', 'Update a task')

View File

@@ -6,7 +6,7 @@ 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('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--init [name]', 'Create a new PRD')
.option('--update [name]', 'Update an existing PRD')
.option('--project <idOrName>', 'Scope to project')

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/config",
"version": "0.0.1",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/config"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -22,6 +27,7 @@
"@mosaic/storage": "workspace:^"
},
"devDependencies": {
"eslint": "^9.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},

View File

@@ -26,7 +26,7 @@ export interface MosaicConfig {
export const DEFAULT_LOCAL_CONFIG: MosaicConfig = {
tier: 'local',
storage: { type: 'sqlite', path: '.mosaic/data.db' },
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' },
};
@@ -43,7 +43,7 @@ export const DEFAULT_TEAM_CONFIG: MosaicConfig = {
/* ------------------------------------------------------------------ */
const VALID_TIERS = new Set<string>(['local', 'team']);
const VALID_STORAGE_TYPES = new Set<string>(['postgres', 'sqlite', 'files']);
const VALID_STORAGE_TYPES = new Set<string>(['postgres', 'pglite', 'files']);
const VALID_QUEUE_TYPES = new Set<string>(['bullmq', 'local']);
const VALID_MEMORY_TYPES = new Set<string>(['pgvector', 'sqlite-vec', 'keyword']);

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/coord",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/coord"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/db",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/db"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -28,6 +33,7 @@
"vitest": "^2.0.0"
},
"dependencies": {
"@electric-sql/pglite": "^0.2.17",
"drizzle-orm": "^0.45.1",
"postgres": "^3.4.8"
},

View File

@@ -0,0 +1,15 @@
import { PGlite } from '@electric-sql/pglite';
import { drizzle } from 'drizzle-orm/pglite';
import * as schema from './schema.js';
import type { DbHandle } from './client.js';
export function createPgliteDb(dataDir: string): DbHandle {
const client = new PGlite(dataDir);
const db = drizzle(client, { schema });
return {
db: db as unknown as DbHandle['db'],
close: async () => {
await client.close();
},
};
}

View File

@@ -1,4 +1,5 @@
export { createDb, type Db, type DbHandle } from './client.js';
export { createPgliteDb } from './client-pglite.js';
export { runMigrations } from './migrate.js';
export * from './schema.js';
export {
@@ -16,4 +17,5 @@ export {
gte,
lte,
ilike,
count,
} from 'drizzle-orm';

View File

@@ -91,6 +91,28 @@ export const verifications = pgTable('verifications', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
// ─── Admin API Tokens ───────────────────────────────────────────────────────
export const adminTokens = pgTable(
'admin_tokens',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull(),
label: text('label').notNull(),
scope: text('scope').notNull().default('admin'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('admin_tokens_user_id_idx').on(t.userId),
uniqueIndex('admin_tokens_hash_idx').on(t.tokenHash),
],
);
// ─── Teams ───────────────────────────────────────────────────────────────────
// Declared before projects because projects references teams.

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/design-tokens",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/design-tokens"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/forge",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/forge"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/log",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/log"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/macp",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/macp"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -1,6 +1,11 @@
{
"name": "@mosaic/memory",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/memory"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -56,24 +56,66 @@ if [[ $fetch -eq 1 ]]; then
if [[ -d "$SKILLS_REPO_DIR/.git" ]]; then
echo "[mosaic-skills] Updating skills source: $SKILLS_REPO_DIR"
# Stash any local changes (dirty index or worktree) before pulling
local_changes=0
if ! git -C "$SKILLS_REPO_DIR" diff --quiet 2>/dev/null || \
! git -C "$SKILLS_REPO_DIR" diff --cached --quiet 2>/dev/null; then
local_changes=1
echo "[mosaic-skills] Stashing local changes..."
git -C "$SKILLS_REPO_DIR" stash push -q -m "mosaic-sync-skills auto-stash"
# ── Detect dirty state ──────────────────────────────────────────────
dirty=""
dirty="$(git -C "$SKILLS_REPO_DIR" status --porcelain 2>/dev/null || true)"
if [[ -n "$dirty" ]]; then
# ── Auto-migrate customized skills to skills-local/ ─────────────
# Instead of stash/pop (fragile, merge conflicts), we:
# 1. Identify which skill dirs contain user edits
# 2. Copy those full skill dirs into skills-local/ (preserving edits)
# 3. Reset the repo clean so pull always succeeds
# 4. skills-local/ takes precedence during linking, so edits win
SOURCE_SKILLS_SUBDIR="$SKILLS_REPO_DIR/skills"
migrated=()
while IFS= read -r line; do
# porcelain format: XY <path> — extract the file path
file="${line:3}"
# Only migrate files under skills/ subdir in the repo
if [[ "$file" == skills/* ]]; then
# Extract the skill directory name (first path component after skills/)
skill_name="${file#skills/}"
skill_name="${skill_name%%/*}"
# Skip if already migrated this skill in this run
local_skill_dir="$MOSAIC_LOCAL_SKILLS_DIR/$skill_name"
if [[ -d "$local_skill_dir" ]]; then
continue
fi
# Skip if skill_name is empty or hidden
if [[ -z "$skill_name" || "$skill_name" == .* ]]; then
continue
fi
# Copy the skill (with user's edits) from repo working tree to skills-local/
if [[ -d "$SOURCE_SKILLS_SUBDIR/$skill_name" ]]; then
cp -R "$SOURCE_SKILLS_SUBDIR/$skill_name" "$local_skill_dir"
migrated+=("$skill_name")
fi
fi
done <<< "$dirty"
if [[ ${#migrated[@]} -gt 0 ]]; then
echo "[mosaic-skills] Migrated ${#migrated[@]} customized skill(s) to skills-local/:"
for s in "${migrated[@]}"; do
echo " → $MOSAIC_LOCAL_SKILLS_DIR/$s"
done
echo "[mosaic-skills] Your edits are preserved there and take precedence over canonical."
fi
# Reset repo to clean state so pull always works
echo "[mosaic-skills] Resetting source repo to clean state..."
git -C "$SKILLS_REPO_DIR" checkout . 2>/dev/null || true
git -C "$SKILLS_REPO_DIR" clean -fd 2>/dev/null || true
fi
if ! git -C "$SKILLS_REPO_DIR" pull --rebase; then
if ! git -C "$SKILLS_REPO_DIR" pull --rebase 2>/dev/null; then
echo "[mosaic-skills] WARN: pull failed — continuing with existing checkout" >&2
fi
# Restore stashed changes
if [[ $local_changes -eq 1 ]]; then
echo "[mosaic-skills] Restoring local changes..."
git -C "$SKILLS_REPO_DIR" stash pop -q 2>/dev/null || \
echo "[mosaic-skills] WARN: stash pop had conflicts — check $SKILLS_REPO_DIR" >&2
git -C "$SKILLS_REPO_DIR" rebase --abort 2>/dev/null || true
fi
else
echo "[mosaic-skills] Cloning skills source to: $SKILLS_REPO_DIR"

View File

@@ -1,18 +1,26 @@
{
"name": "@mosaic/mosaic",
"version": "0.0.3-1",
"version": "0.0.19",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
"directory": "packages/mosaic"
},
"description": "Mosaic agent framework — installation wizard and meta package",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"mosaic": "dist/cli.js",
"mosaic-wizard": "dist/index.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./package.json": "./package.json",
"./framework/*": "./framework/*"
},
"scripts": {
"build": "tsc",
@@ -21,19 +29,27 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaic/config": "workspace:*",
"@mosaic/forge": "workspace:*",
"@mosaic/macp": "workspace:*",
"@mosaic/prdy": "workspace:*",
"@mosaic/quality-rails": "workspace:*",
"@mosaic/types": "workspace:*",
"@clack/prompts": "^0.9.1",
"commander": "^12.1.0",
"commander": "^13.0.0",
"ink": "^5.0.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"picocolors": "^1.1.1",
"react": "^18.3.0",
"socket.io-client": "^4.8.0",
"yaml": "^2.6.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},

115
packages/mosaic/src/auth.ts Normal file
View File

@@ -0,0 +1,115 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { homedir } from 'node:os';
const SESSION_DIR = resolve(homedir(), '.mosaic');
const SESSION_FILE = resolve(SESSION_DIR, 'session.json');
interface StoredSession {
gatewayUrl: string;
cookie: string;
userId: string;
email: string;
expiresAt: string;
}
export interface AuthResult {
cookie: string;
userId: string;
email: string;
}
/**
* Sign in to the gateway and return the session cookie.
*/
export async function signIn(
gatewayUrl: string,
email: string,
password: string,
): Promise<AuthResult> {
const res = await fetch(`${gatewayUrl}/api/auth/sign-in/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: gatewayUrl },
body: JSON.stringify({ email, password }),
redirect: 'manual',
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Sign-in failed (${res.status}): ${body}`);
}
// Extract set-cookie header
const setCookieHeader = res.headers.getSetCookie?.() ?? [];
const sessionCookie = setCookieHeader
.map((c) => c.split(';')[0]!)
.filter((c) => c.startsWith('better-auth.session_token='))
.join('; ');
if (!sessionCookie) {
throw new Error('No session cookie returned from sign-in');
}
// Parse the response body for user info
const data = (await res.json()) as { user?: { id: string; email: string } };
const userId = data.user?.id ?? 'unknown';
const userEmail = data.user?.email ?? email;
return { cookie: sessionCookie, userId, email: userEmail };
}
/**
* Save session to ~/.mosaic/session.json
*/
export function saveSession(gatewayUrl: string, auth: AuthResult): void {
if (!existsSync(SESSION_DIR)) {
mkdirSync(SESSION_DIR, { recursive: true });
}
const session: StoredSession = {
gatewayUrl,
cookie: auth.cookie,
userId: auth.userId,
email: auth.email,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
};
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8');
}
/**
* Load a saved session. Returns null if no session, expired, or wrong gateway.
*/
export function loadSession(gatewayUrl: string): AuthResult | null {
if (!existsSync(SESSION_FILE)) return null;
try {
const raw = readFileSync(SESSION_FILE, 'utf-8');
const session = JSON.parse(raw) as StoredSession;
if (session.gatewayUrl !== gatewayUrl) return null;
if (new Date(session.expiresAt) < new Date()) return null;
return {
cookie: session.cookie,
userId: session.userId,
email: session.email,
};
} catch {
return null;
}
}
/**
* Validate that a stored session is still active by hitting get-session.
*/
export async function validateSession(gatewayUrl: string, cookie: string): Promise<boolean> {
try {
const res = await fetch(`${gatewayUrl}/api/auth/get-session`, {
headers: { Cookie: cookie, Origin: gatewayUrl },
});
return res.ok;
} catch {
return false;
}
}

425
packages/mosaic/src/cli.ts Normal file
View File

@@ -0,0 +1,425 @@
#!/usr/bin/env node
import { createRequire } from 'module';
import { Command } from 'commander';
import { registerQualityRails } from '@mosaic/quality-rails';
import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js';
// prdy is registered via launch.ts
import { registerLaunchCommands } from './commands/launch.js';
import { registerGatewayCommand } from './commands/gateway.js';
import {
backgroundUpdateCheck,
checkForUpdate,
formatUpdateNotice,
} from './runtime/update-checker.js';
import { runWizard } from './wizard.js';
import { ClackPrompter } from './prompter/clack-prompter.js';
import { HeadlessPrompter } from './prompter/headless-prompter.js';
import { createConfigService } from './config/config-service.js';
import { WizardCancelledError } from './errors.js';
import { DEFAULT_MOSAIC_HOME } from './constants.js';
const _require = createRequire(import.meta.url);
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
// Fire-and-forget update check at startup (non-blocking, cached 1h)
try {
backgroundUpdateCheck();
} catch {
// Silently ignore — update check is best-effort
}
const program = new Command();
program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION);
// ─── runtime launchers + framework commands ────────────────────────────
registerLaunchCommands(program);
// ─── login ──────────────────────────────────────────────────────────────
program
.command('login')
.description('Sign in to a Mosaic gateway')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('-e, --email <email>', 'Email address')
.option('-p, --password <password>', 'Password')
.action(async (opts: { gateway: string; email?: string; password?: string }) => {
const { signIn, saveSession } = await import('./auth.js');
let email = opts.email;
let password = opts.password;
if (!email || !password) {
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));
if (!email) email = await ask('Email: ');
if (!password) password = await ask('Password: ');
rl.close();
}
try {
const auth = await signIn(opts.gateway, email, password);
saveSession(opts.gateway, auth);
console.log(`Signed in as ${auth.email} (${opts.gateway})`);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── tui ────────────────────────────────────────────────────────────────
program
.command('tui')
.description('Launch interactive TUI connected to the gateway')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('-c, --conversation <id>', 'Resume a conversation by ID')
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
.option('--agent <idOrName>', 'Connect to a specific agent')
.option('--project <idOrName>', 'Scope session to project')
.action(
async (opts: {
gateway: string;
conversation?: string;
model?: string;
provider?: string;
agent?: string;
project?: string;
}) => {
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
// Try loading saved session
let session = loadSession(opts.gateway);
if (session) {
const valid = await validateSession(opts.gateway, session.cookie);
if (!valid) {
console.log('Session expired. Please sign in again.');
session = null;
}
}
// No valid session — prompt for credentials
if (!session) {
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));
console.log(`Sign in to ${opts.gateway}`);
const email = await ask('Email: ');
const password = await ask('Password: ');
rl.close();
try {
const auth = await signIn(opts.gateway, email, password);
saveSession(opts.gateway, auth);
session = auth;
console.log(`Signed in as ${auth.email}\n`);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
// 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);
}
}
// Auto-create a conversation if none was specified
let conversationId = opts.conversation;
if (!conversationId) {
try {
const { createConversation } = await import('./tui/gateway-api.js');
const conv = await createConversation(opts.gateway, session.cookie, {
...(projectId ? { projectId } : {}),
});
conversationId = conv.id;
} catch (err) {
console.error(
`Failed to create conversation: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
}
// Dynamic import to avoid loading React/Ink for other commands
const { render } = await import('ink');
const React = await import('react');
const { TuiApp } = await import('./tui/app.js');
render(
React.createElement(TuiApp, {
gatewayUrl: opts.gateway,
conversationId,
sessionCookie: session.cookie,
initialModel: opts.model,
initialProvider: opts.provider,
agentId,
agentName: agentName ?? undefined,
projectId,
version: CLI_VERSION,
}),
{ exitOnCtrlC: false },
);
},
);
// ─── sessions ───────────────────────────────────────────────────────────
const sessionsCmd = program.command('sessions').description('Manage active agent sessions');
sessionsCmd
.command('list')
.description('List active agent sessions')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (opts: { gateway: string }) => {
const { withAuth } = await import('./commands/with-auth.js');
const auth = await withAuth(opts.gateway);
const { fetchSessions } = await import('./tui/gateway-api.js');
try {
const result = await fetchSessions(auth.gateway, auth.cookie);
if (result.total === 0) {
console.log('No active sessions.');
return;
}
console.log(`Active sessions (${result.total}):\n`);
for (const s of result.sessions) {
const created = new Date(s.createdAt).toLocaleString();
const durationSec = Math.round(s.durationMs / 1000);
console.log(` ID: ${s.id}`);
console.log(` Model: ${s.provider}/${s.modelId}`);
console.log(` Created: ${created}`);
console.log(` Prompts: ${s.promptCount}`);
console.log(` Duration: ${durationSec}s`);
if (s.channels.length > 0) {
console.log(` Channels: ${s.channels.join(', ')}`);
}
console.log('');
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
sessionsCmd
.command('resume <id>')
.description('Resume an existing agent session in the TUI')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (id: string, opts: { gateway: string }) => {
const { loadSession, validateSession } = await import('./auth.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);
}
const { render } = await import('ink');
const React = await import('react');
const { TuiApp } = await import('./tui/app.js');
render(
React.createElement(TuiApp, {
gatewayUrl: opts.gateway,
conversationId: id,
sessionCookie: session.cookie,
version: CLI_VERSION,
}),
);
});
sessionsCmd
.command('destroy <id>')
.description('Terminate an active agent session')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (id: string, opts: { gateway: string }) => {
const { withAuth } = await import('./commands/with-auth.js');
const auth = await withAuth(opts.gateway);
const { deleteSession } = await import('./tui/gateway-api.js');
try {
await deleteSession(auth.gateway, auth.cookie, id);
console.log(`Session ${id} destroyed.`);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── gateway ──────────────────────────────────────────────────────────
registerGatewayCommand(program);
// ─── agent ─────────────────────────────────────────────────────────────
registerAgentCommand(program);
// ─── mission ───────────────────────────────────────────────────────────
registerMissionCommand(program);
// ─── quality-rails ──────────────────────────────────────────────────────
registerQualityRails(program);
// ─── update ─────────────────────────────────────────────────────────────
program
.command('update')
.description('Check for and install Mosaic CLI updates')
.option('--check', 'Check only, do not install')
.action(async (opts: { check?: boolean }) => {
// checkForUpdate and formatUpdateNotice imported statically above
const { execSync } = await import('node:child_process');
console.log('Checking for updates…');
const result = checkForUpdate({ skipCache: true });
if (!result.latest) {
console.error('Could not reach the Mosaic registry.');
process.exit(1);
}
console.log(` Installed: ${result.current || '(none)'}`);
console.log(` Latest: ${result.latest}`);
if (!result.updateAvailable) {
console.log('\n✔ Up to date.');
return;
}
const notice = formatUpdateNotice(result);
if (notice) console.log(notice);
if (opts.check) {
process.exit(2); // Signal to callers that an update exists
}
console.log('Installing update…');
try {
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
// globally or non-@mosaic deps will 404 against the Gitea registry.
execSync('npm install -g @mosaic/cli@latest', {
stdio: 'inherit',
timeout: 60_000,
});
console.log('\n✔ Updated successfully.');
} catch {
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
process.exit(1);
}
});
// ─── wizard ─────────────────────────────────────────────────────────────
program
.command('wizard')
.description('Run the Mosaic installation wizard')
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
.option('--source-dir <path>', 'Source directory for framework files')
.option('--mosaic-home <path>', 'Target config directory')
.option('--name <name>', 'Agent name')
.option('--role <description>', 'Agent role description')
.option('--style <style>', 'Communication style: direct|friendly|formal')
.option('--accessibility <prefs>', 'Accessibility preferences')
.option('--guardrails <rules>', 'Custom guardrails')
.option('--user-name <name>', 'Your name')
.option('--pronouns <pronouns>', 'Your pronouns')
.option('--timezone <tz>', 'Your timezone')
.action(async (opts: Record<string, string | boolean | undefined>) => {
// All wizard imports are now static (see top of file)
try {
const mosaicHome = (opts['mosaicHome'] as string | undefined) ?? DEFAULT_MOSAIC_HOME;
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
const configService = createConfigService(mosaicHome, sourceDir);
await runWizard({
mosaicHome,
sourceDir,
prompter,
configService,
cliOverrides: {
soul: {
agentName: opts['name'] as string | undefined,
roleDescription: opts['role'] as string | undefined,
communicationStyle: opts['style'] as 'direct' | 'friendly' | 'formal' | undefined,
accessibility: opts['accessibility'] as string | undefined,
customGuardrails: opts['guardrails'] as string | undefined,
},
user: {
userName: opts['userName'] as string | undefined,
pronouns: opts['pronouns'] as string | undefined,
timezone: opts['timezone'] as string | undefined,
},
},
});
} catch (err) {
if (err instanceof WizardCancelledError) {
console.log('\nWizard cancelled.');
process.exit(0);
}
console.error('Wizard failed:', err);
process.exit(1);
}
});
program.parse();

View File

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

View File

@@ -0,0 +1,152 @@
import type { Command } from 'commander';
import {
getDaemonPid,
readMeta,
startDaemon,
stopDaemon,
waitForHealth,
} from './gateway/daemon.js';
interface GatewayParentOpts {
host: string;
port: string;
token?: string;
}
function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; token?: string } {
const meta = readMeta();
return {
host: raw.host ?? meta?.host ?? 'localhost',
port: parseInt(raw.port, 10) || meta?.port || 14242,
token: raw.token ?? meta?.adminToken,
};
}
export function registerGatewayCommand(program: Command): void {
const gw = program
.command('gateway')
.description('Manage the Mosaic gateway daemon')
.helpOption('--help', 'Display help')
.option('-h, --host <host>', 'Gateway host', 'localhost')
.option('-p, --port <port>', 'Gateway port', '14242')
.option('-t, --token <token>', 'Admin API token')
.action(() => {
gw.outputHelp();
});
// ─── install ────────────────────────────────────────────────────────────
gw.command('install')
.description('Install and configure the gateway daemon')
.option('--skip-install', 'Skip npm package installation (use local build)')
.action(async (cmdOpts: { skipInstall?: boolean }) => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const { runInstall } = await import('./gateway/install.js');
await runInstall({ ...opts, skipInstall: cmdOpts.skipInstall });
});
// ─── start ──────────────────────────────────────────────────────────────
gw.command('start')
.description('Start the gateway daemon')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
try {
const pid = startDaemon();
console.log(`Gateway started (PID ${pid.toString()})`);
console.log('Waiting for health...');
const healthy = await waitForHealth(opts.host, opts.port);
if (healthy) {
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
} else {
console.warn('Gateway started but health check timed out. Check logs.');
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── stop ───────────────────────────────────────────────────────────────
gw.command('stop')
.description('Stop the gateway daemon')
.action(async () => {
try {
await stopDaemon();
console.log('Gateway stopped.');
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── restart ────────────────────────────────────────────────────────────
gw.command('restart')
.description('Restart the gateway daemon')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const pid = getDaemonPid();
if (pid !== null) {
console.log('Stopping gateway...');
await stopDaemon();
}
console.log('Starting gateway...');
try {
const newPid = startDaemon();
console.log(`Gateway started (PID ${newPid.toString()})`);
const healthy = await waitForHealth(opts.host, opts.port);
if (healthy) {
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
} else {
console.warn('Gateway started but health check timed out. Check logs.');
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── status ─────────────────────────────────────────────────────────────
gw.command('status')
.description('Show gateway daemon status and health')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const { runStatus } = await import('./gateway/status.js');
await runStatus(opts);
});
// ─── config ─────────────────────────────────────────────────────────────
gw.command('config')
.description('View or modify gateway configuration')
.option('--set <KEY=VALUE>', 'Set a configuration value')
.option('--unset <KEY>', 'Remove a configuration key')
.option('--edit', 'Open config in $EDITOR')
.action(async (cmdOpts: { set?: string; unset?: string; edit?: boolean }) => {
const { runConfig } = await import('./gateway/config.js');
await runConfig(cmdOpts);
});
// ─── logs ───────────────────────────────────────────────────────────────
gw.command('logs')
.description('View gateway daemon logs')
.option('-f, --follow', 'Follow log output')
.option('-n, --lines <count>', 'Number of lines to show', '50')
.action(async (cmdOpts: { follow?: boolean; lines?: string }) => {
const { runLogs } = await import('./gateway/logs.js');
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
});
// ─── uninstall ──────────────────────────────────────────────────────────
gw.command('uninstall')
.description('Uninstall the gateway daemon and optionally remove data')
.action(async () => {
const { runUninstall } = await import('./gateway/uninstall.js');
await runUninstall();
});
}

View File

@@ -0,0 +1,143 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { ENV_FILE, getDaemonPid, readMeta, META_FILE, ensureDirs } from './daemon.js';
// Keys that should be masked in output
const SECRET_KEYS = new Set([
'BETTER_AUTH_SECRET',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'ZAI_API_KEY',
'OPENROUTER_API_KEY',
'DISCORD_BOT_TOKEN',
'TELEGRAM_BOT_TOKEN',
]);
function maskValue(key: string, value: string): string {
if (SECRET_KEYS.has(key) && value.length > 8) {
return value.slice(0, 4) + '…' + value.slice(-4);
}
return value;
}
function parseEnvFile(): Map<string, string> {
const map = new Map<string, string>();
if (!existsSync(ENV_FILE)) return map;
const lines = readFileSync(ENV_FILE, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
map.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
}
return map;
}
function writeEnvFile(entries: Map<string, string>): void {
ensureDirs();
const lines: string[] = [];
for (const [key, value] of entries) {
lines.push(`${key}=${value}`);
}
writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
}
interface ConfigOpts {
set?: string;
unset?: string;
edit?: boolean;
}
export async function runConfig(opts: ConfigOpts): Promise<void> {
// Set a value
if (opts.set) {
const eqIdx = opts.set.indexOf('=');
if (eqIdx === -1) {
console.error('Usage: mosaic gateway config --set KEY=VALUE');
process.exit(1);
}
const key = opts.set.slice(0, eqIdx);
const value = opts.set.slice(eqIdx + 1);
const entries = parseEnvFile();
entries.set(key, value);
writeEnvFile(entries);
console.log(`Set ${key}=${maskValue(key, value)}`);
promptRestart();
return;
}
// Unset a value
if (opts.unset) {
const entries = parseEnvFile();
if (!entries.has(opts.unset)) {
console.error(`Key not found: ${opts.unset}`);
process.exit(1);
}
entries.delete(opts.unset);
writeEnvFile(entries);
console.log(`Removed ${opts.unset}`);
promptRestart();
return;
}
// Open in editor
if (opts.edit) {
if (!existsSync(ENV_FILE)) {
console.error(`No config file found at ${ENV_FILE}`);
console.error('Run `mosaic gateway install` first.');
process.exit(1);
}
const editor = process.env['EDITOR'] ?? process.env['VISUAL'] ?? 'vi';
try {
execSync(`${editor} "${ENV_FILE}"`, { stdio: 'inherit' });
promptRestart();
} catch {
console.error('Editor exited with error.');
}
return;
}
// Default: show current config
showConfig();
}
function showConfig(): void {
if (!existsSync(ENV_FILE)) {
console.log('No gateway configuration found.');
console.log('Run `mosaic gateway install` to set up.');
return;
}
const entries = parseEnvFile();
const meta = readMeta();
console.log('Mosaic Gateway Configuration');
console.log('────────────────────────────');
console.log(` Config file: ${ENV_FILE}`);
console.log(` Meta file: ${META_FILE}`);
console.log();
if (entries.size === 0) {
console.log(' (empty)');
return;
}
const maxKeyLen = Math.max(...[...entries.keys()].map((k) => k.length));
for (const [key, value] of entries) {
const padding = ' '.repeat(maxKeyLen - key.length);
console.log(` ${key}${padding} ${maskValue(key, value)}`);
}
if (meta?.adminToken) {
console.log();
console.log(` Admin token: ${maskValue('token', meta.adminToken)}`);
}
}
function promptRestart(): void {
if (getDaemonPid() !== null) {
console.log('\nGateway is running — restart to apply changes: mosaic gateway restart');
}
}

View File

@@ -0,0 +1,245 @@
import { spawn, execSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
openSync,
constants,
} from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { createRequire } from 'node:module';
// ─── Paths ──────────────────────────────────────────────────────────────────
export const GATEWAY_HOME = resolve(
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
);
export const PID_FILE = join(GATEWAY_HOME, 'daemon.pid');
export const LOG_DIR = join(GATEWAY_HOME, 'logs');
export const LOG_FILE = join(LOG_DIR, 'gateway.log');
export const ENV_FILE = join(GATEWAY_HOME, '.env');
export const META_FILE = join(GATEWAY_HOME, 'meta.json');
// ─── Meta ───────────────────────────────────────────────────────────────────
export interface GatewayMeta {
version: string;
installedAt: string;
entryPoint: string;
adminToken?: string;
host: string;
port: number;
}
export function readMeta(): GatewayMeta | null {
if (!existsSync(META_FILE)) return null;
try {
return JSON.parse(readFileSync(META_FILE, 'utf-8')) as GatewayMeta;
} catch {
return null;
}
}
export function writeMeta(meta: GatewayMeta): void {
ensureDirs();
writeFileSync(META_FILE, JSON.stringify(meta, null, 2), { mode: 0o600 });
}
// ─── Directories ────────────────────────────────────────────────────────────
export function ensureDirs(): void {
mkdirSync(GATEWAY_HOME, { recursive: true, mode: 0o700 });
mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
}
// ─── PID management ─────────────────────────────────────────────────────────
export function readPid(): number | null {
if (!existsSync(PID_FILE)) return null;
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
export function isRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function getDaemonPid(): number | null {
const pid = readPid();
if (pid === null) return null;
return isRunning(pid) ? pid : null;
}
// ─── Entry point resolution ─────────────────────────────────────────────────
export function resolveGatewayEntry(): string {
// Check meta.json for custom entry point
const meta = readMeta();
if (meta?.entryPoint && existsSync(meta.entryPoint)) {
return meta.entryPoint;
}
// Try to resolve from globally installed @mosaic/gateway
try {
const req = createRequire(import.meta.url);
const pkgPath = req.resolve('@mosaic/gateway/package.json');
const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js');
if (existsSync(mainEntry)) return mainEntry;
} catch {
// Not installed globally
}
throw new Error('Cannot find gateway entry point. Run `mosaic gateway install` first.');
}
// ─── Start / Stop / Health ──────────────────────────────────────────────────
export function startDaemon(): number {
const running = getDaemonPid();
if (running !== null) {
throw new Error(`Gateway is already running (PID ${running.toString()})`);
}
ensureDirs();
const entryPoint = resolveGatewayEntry();
// Load env vars from gateway .env
const env: Record<string, string> = { ...process.env } as Record<string, string>;
if (existsSync(ENV_FILE)) {
for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx > 0) env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
}
}
const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND);
const child = spawn('node', [entryPoint], {
detached: true,
stdio: ['ignore', logFd, logFd],
env,
cwd: GATEWAY_HOME,
});
if (!child.pid) {
throw new Error('Failed to spawn gateway process');
}
writeFileSync(PID_FILE, child.pid.toString(), { mode: 0o600 });
child.unref();
return child.pid;
}
export async function stopDaemon(timeoutMs = 10_000): Promise<void> {
const pid = getDaemonPid();
if (pid === null) {
throw new Error('Gateway is not running');
}
process.kill(pid, 'SIGTERM');
// Poll for exit
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!isRunning(pid)) {
cleanPidFile();
return;
}
await sleep(250);
}
// Force kill
try {
process.kill(pid, 'SIGKILL');
} catch {
// Already dead
}
cleanPidFile();
}
function cleanPidFile(): void {
try {
unlinkSync(PID_FILE);
} catch {
// Ignore
}
}
export async function waitForHealth(
host: string,
port: number,
timeoutMs = 30_000,
): Promise<boolean> {
const start = Date.now();
let delay = 500;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(`http://${host}:${port.toString()}/health`);
if (res.ok) return true;
} catch {
// Not ready yet
}
await sleep(delay);
delay = Math.min(delay * 1.5, 3000);
}
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── npm install helper ─────────────────────────────────────────────────────
const GITEA_REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
export function installGatewayPackage(): void {
console.log('Installing @mosaic/gateway from Gitea registry...');
execSync(`npm install -g @mosaic/gateway@latest --@mosaic:registry=${GITEA_REGISTRY}`, {
stdio: 'inherit',
timeout: 120_000,
});
}
export function uninstallGatewayPackage(): void {
try {
execSync('npm uninstall -g @mosaic/gateway', {
stdio: 'inherit',
timeout: 60_000,
});
} catch {
console.warn('Warning: npm uninstall may not have completed cleanly.');
}
}
export function getInstalledGatewayVersion(): string | null {
try {
const output = execSync('npm ls -g @mosaic/gateway --json --depth=0', {
encoding: 'utf-8',
timeout: 15_000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const data = JSON.parse(output) as {
dependencies?: { '@mosaic/gateway'?: { version?: string } };
};
return data.dependencies?.['@mosaic/gateway']?.version ?? null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,259 @@
import { randomBytes } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
import type { GatewayMeta } from './daemon.js';
import {
ENV_FILE,
GATEWAY_HOME,
ensureDirs,
installGatewayPackage,
readMeta,
resolveGatewayEntry,
startDaemon,
waitForHealth,
writeMeta,
getInstalledGatewayVersion,
} from './daemon.js';
interface InstallOpts {
host: string;
port: number;
skipInstall?: boolean;
}
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
export async function runInstall(opts: InstallOpts): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
await doInstall(rl, opts);
} finally {
rl.close();
}
}
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
// Check existing installation
const existing = readMeta();
if (existing) {
const answer = await prompt(
rl,
`Gateway already installed (v${existing.version}). Reinstall? [y/N] `,
);
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
}
}
// Step 1: Install npm package
if (!opts.skipInstall) {
installGatewayPackage();
}
ensureDirs();
// Step 2: Collect configuration
console.log('\n─── Gateway Configuration ───\n');
// Tier selection
console.log('Storage tier:');
console.log(' 1. Local (embedded database, no dependencies)');
console.log(' 2. Team (PostgreSQL + Valkey required)');
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
const tier = tierAnswer === '2' ? 'team' : 'local';
const port =
opts.port !== 14242
? opts.port
: parseInt(
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
10,
);
let databaseUrl: string | undefined;
let valkeyUrl: string | undefined;
if (tier === 'team') {
databaseUrl =
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
valkeyUrl =
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
}
const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
const corsOrigin =
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
// Generate auth secret
const authSecret = randomBytes(32).toString('hex');
// Step 3: Write .env
const envLines = [
`GATEWAY_PORT=${port.toString()}`,
`BETTER_AUTH_SECRET=${authSecret}`,
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
`OTEL_SERVICE_NAME=mosaic-gateway`,
];
if (tier === 'team' && databaseUrl && valkeyUrl) {
envLines.push(`DATABASE_URL=${databaseUrl}`);
envLines.push(`VALKEY_URL=${valkeyUrl}`);
}
if (anthropicKey) {
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
}
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
console.log(`\nConfig written to ${ENV_FILE}`);
// Step 3b: Write mosaic.config.json
const mosaicConfig =
tier === 'local'
? {
tier: 'local',
storage: { type: 'pglite', dataDir: join(GATEWAY_HOME, 'storage-pglite') },
queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') },
memory: { type: 'keyword' },
}
: {
tier: 'team',
storage: { type: 'postgres', url: databaseUrl },
queue: { type: 'bullmq', url: valkeyUrl },
memory: { type: 'pgvector' },
};
const configFile = join(GATEWAY_HOME, 'mosaic.config.json');
writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
console.log(`Config written to ${configFile}`);
// Step 4: Write meta.json
let entryPoint: string;
try {
entryPoint = resolveGatewayEntry();
} catch {
console.error('Error: Gateway package not found after install.');
console.error('Check that @mosaic/gateway installed correctly.');
return;
}
const version = getInstalledGatewayVersion() ?? 'unknown';
const meta = {
version,
installedAt: new Date().toISOString(),
entryPoint,
host: opts.host,
port,
};
writeMeta(meta);
// Step 5: Start the daemon
console.log('\nStarting gateway daemon...');
try {
const pid = startDaemon();
console.log(`Gateway started (PID ${pid.toString()})`);
} catch (err) {
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
return;
}
// Step 6: Wait for health
console.log('Waiting for gateway to become healthy...');
const healthy = await waitForHealth(opts.host, port, 30_000);
if (!healthy) {
console.error('Gateway did not become healthy within 30 seconds.');
console.error(`Check logs: mosaic gateway logs`);
return;
}
console.log('Gateway is healthy.\n');
// Step 7: Bootstrap — first user setup
await bootstrapFirstUser(rl, opts.host, port, meta);
console.log('\n─── Installation Complete ───');
console.log(` Endpoint: http://${opts.host}:${port.toString()}`);
console.log(` Config: ${GATEWAY_HOME}`);
console.log(` Logs: mosaic gateway logs`);
console.log(` Status: mosaic gateway status`);
}
async function bootstrapFirstUser(
rl: ReturnType<typeof createInterface>,
host: string,
port: number,
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string },
): Promise<void> {
const baseUrl = `http://${host}:${port.toString()}`;
try {
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
if (!statusRes.ok) return;
const status = (await statusRes.json()) as { needsSetup: boolean };
if (!status.needsSetup) {
console.log('Admin user already exists — skipping setup.');
return;
}
} catch {
console.warn('Could not check bootstrap status — skipping first user setup.');
return;
}
console.log('─── Admin User Setup ───\n');
const name = (await prompt(rl, 'Admin name: ')).trim();
if (!name) {
console.error('Name is required.');
return;
}
const email = (await prompt(rl, 'Admin email: ')).trim();
if (!email) {
console.error('Email is required.');
return;
}
const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim();
if (password.length < 8) {
console.error('Password must be at least 8 characters.');
return;
}
try {
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
console.error(`Bootstrap failed (${res.status.toString()}): ${body}`);
return;
}
const result = (await res.json()) as {
user: { id: string; email: string };
token: { plaintext: string };
};
// Save admin token to meta
meta.adminToken = result.token.plaintext;
writeMeta(meta as GatewayMeta);
console.log(`\nAdmin user created: ${result.user.email}`);
console.log('Admin API token saved to gateway config.');
} catch (err) {
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
}
}

View File

@@ -0,0 +1,37 @@
import { existsSync, readFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { LOG_FILE } from './daemon.js';
interface LogsOpts {
follow?: boolean;
lines?: number;
}
export function runLogs(opts: LogsOpts): void {
if (!existsSync(LOG_FILE)) {
console.log('No log file found. Is the gateway installed?');
return;
}
if (opts.follow) {
const lines = opts.lines ?? 50;
const tail = spawn('tail', ['-n', lines.toString(), '-f', LOG_FILE], {
stdio: 'inherit',
});
tail.on('error', () => {
// Fallback for systems without tail
console.log(readLastLines(opts.lines ?? 50));
console.log('\n(--follow requires `tail` command)');
});
return;
}
// Just print last N lines
console.log(readLastLines(opts.lines ?? 50));
}
function readLastLines(n: number): string {
const content = readFileSync(LOG_FILE, 'utf-8');
const lines = content.split('\n');
return lines.slice(-n).join('\n');
}

View File

@@ -0,0 +1,115 @@
import { getDaemonPid, readMeta, LOG_FILE, GATEWAY_HOME } from './daemon.js';
interface GatewayOpts {
host: string;
port: number;
token?: string;
}
interface ServiceStatus {
name: string;
status: string;
latency?: string;
}
interface AdminHealth {
status: string;
services: {
database: { status: string; latencyMs: number };
cache: { status: string; latencyMs: number };
};
agentPool?: { active: number };
providers?: Array<{ name: string; available: boolean; models: number }>;
}
export async function runStatus(opts: GatewayOpts): Promise<void> {
const meta = readMeta();
const pid = getDaemonPid();
console.log('Mosaic Gateway Status');
console.log('─────────────────────');
// Daemon status
if (pid !== null) {
console.log(` Status: running (PID ${pid.toString()})`);
} else {
console.log(' Status: stopped');
}
// Version
console.log(` Version: ${meta?.version ?? 'unknown'}`);
// Endpoint
const host = opts.host;
const port = opts.port;
console.log(` Endpoint: http://${host}:${port.toString()}`);
console.log(` Config: ${GATEWAY_HOME}`);
console.log(` Logs: ${LOG_FILE}`);
if (pid === null) return;
// Health check
try {
const healthRes = await fetch(`http://${host}:${port.toString()}/health`);
if (!healthRes.ok) {
console.log('\n Health: unreachable');
return;
}
} catch {
console.log('\n Health: unreachable');
return;
}
// Admin health (requires token)
const token = opts.token ?? meta?.adminToken;
if (!token) {
console.log(
'\n (No admin token — run `mosaic gateway config` to set one for detailed status)',
);
return;
}
try {
const res = await fetch(`http://${host}:${port.toString()}/api/admin/health`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
console.log('\n Admin health: unauthorized or unavailable');
return;
}
const health = (await res.json()) as AdminHealth;
console.log('\n Services:');
const services: ServiceStatus[] = [
{
name: 'Database',
status: health.services.database.status,
latency: `${health.services.database.latencyMs.toString()}ms`,
},
{
name: 'Cache',
status: health.services.cache.status,
latency: `${health.services.cache.latencyMs.toString()}ms`,
},
];
for (const svc of services) {
const latStr = svc.latency ? ` (${svc.latency})` : '';
console.log(` ${svc.name}:${' '.repeat(10 - svc.name.length)}${svc.status}${latStr}`);
}
if (health.providers && health.providers.length > 0) {
const available = health.providers.filter((p) => p.available);
const names = available.map((p) => p.name).join(', ');
console.log(`\n Providers: ${available.length.toString()} active (${names})`);
}
if (health.agentPool) {
console.log(` Sessions: ${health.agentPool.active.toString()} active`);
}
} catch {
console.log('\n Admin health: connection error');
}
}

View File

@@ -0,0 +1,62 @@
import { existsSync, rmSync } from 'node:fs';
import { createInterface } from 'node:readline';
import {
GATEWAY_HOME,
getDaemonPid,
readMeta,
stopDaemon,
uninstallGatewayPackage,
} from './daemon.js';
export async function runUninstall(): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
await doUninstall(rl);
} finally {
rl.close();
}
}
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
const meta = readMeta();
if (!meta) {
console.log('Gateway is not installed.');
return;
}
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
}
// Stop if running
if (getDaemonPid() !== null) {
console.log('Stopping gateway daemon...');
try {
await stopDaemon();
console.log('Stopped.');
} catch (err) {
console.warn(`Warning: ${err instanceof Error ? err.message : String(err)}`);
}
}
// Remove config/data
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
if (removeData.toLowerCase() === 'y') {
if (existsSync(GATEWAY_HOME)) {
rmSync(GATEWAY_HOME, { recursive: true, force: true });
console.log('Gateway data removed.');
}
}
// Uninstall npm package
console.log('Uninstalling npm package...');
uninstallGatewayPackage();
console.log('\nGateway uninstalled.');
}

View File

@@ -0,0 +1,768 @@
/**
* Native runtime launcher — replaces the bash mosaic-launch script.
*
* Builds a composed runtime prompt from AGENTS.md + RUNTIME.md + USER.md +
* TOOLS.md + mission context + PRD status, then exec's into the target CLI.
*/
import { execFileSync, execSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Command } from 'commander';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
const RUNTIME_LABELS: Record<RuntimeName, string> = {
claude: 'Claude Code',
codex: 'Codex',
opencode: 'OpenCode',
pi: 'Pi',
};
// ─── Pre-flight checks ──────────────────────────────────────────────────────
function checkMosaicHome(): void {
if (!existsSync(MOSAIC_HOME)) {
console.error(`[mosaic] ERROR: ${MOSAIC_HOME} not found.`);
console.error(
'[mosaic] Install: bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)',
);
process.exit(1);
}
}
function checkFile(path: string, label: string): void {
if (!existsSync(path)) {
console.error(`[mosaic] ERROR: ${label} not found: ${path}`);
process.exit(1);
}
}
function checkRuntime(cmd: string): void {
try {
execSync(`which ${cmd}`, { stdio: 'ignore' });
} catch {
console.error(`[mosaic] ERROR: '${cmd}' not found in PATH.`);
console.error(`[mosaic] Install ${cmd} before launching.`);
process.exit(1);
}
}
function checkSoul(): void {
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
if (!existsSync(soulPath)) {
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
// Prefer the TypeScript wizard (idempotent, detects existing files)
try {
const result = spawnSync(process.execPath, [process.argv[1]!, 'wizard'], {
stdio: 'inherit',
});
if (result.status === 0 && existsSync(soulPath)) return;
} catch {
// Fall through to legacy init
}
// Fallback: legacy bash mosaic-init
const initBin = fwScript('mosaic-init');
if (existsSync(initBin)) {
spawnSync(initBin, [], { stdio: 'inherit' });
} else {
console.error('[mosaic] Setup failed. Run: mosaic wizard');
process.exit(1);
}
}
}
function checkSequentialThinking(runtime: string): void {
const checker = fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return; // Skip if checker doesn't exist
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
if (result.status !== 0) {
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`);
process.exit(1);
}
}
// ─── File helpers ────────────────────────────────────────────────────────────
function readOptional(path: string): string {
try {
return readFileSync(path, 'utf-8');
} catch {
return '';
}
}
function readJson(path: string): Record<string, unknown> | null {
try {
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
} catch {
return null;
}
}
// ─── Mission context ─────────────────────────────────────────────────────────
interface MissionInfo {
name: string;
id: string;
status: string;
milestoneCount: number;
completedCount: number;
}
function detectMission(): MissionInfo | null {
const missionFile = '.mosaic/orchestrator/mission.json';
const data = readJson(missionFile);
if (!data) return null;
const status = String(data['status'] ?? 'inactive');
if (status !== 'active' && status !== 'paused') return null;
const milestones = Array.isArray(data['milestones']) ? data['milestones'] : [];
const completed = milestones.filter(
(m) =>
typeof m === 'object' &&
m !== null &&
(m as Record<string, unknown>)['status'] === 'completed',
);
return {
name: String(data['name'] ?? 'unnamed'),
id: String(data['mission_id'] ?? ''),
status,
milestoneCount: milestones.length,
completedCount: completed.length,
};
}
function buildMissionBlock(mission: MissionInfo): string {
return `# ACTIVE MISSION — HARD GATE (Read Before Anything Else)
An active orchestration mission exists in this project. This is a BLOCKING requirement.
**Mission:** ${mission.name}
**ID:** ${mission.id}
**Status:** ${mission.status}
**Milestones:** ${mission.completedCount} / ${mission.milestoneCount} completed
## MANDATORY — Before ANY Response to the User
You MUST complete these steps before responding to any user message, including simple greetings:
1. Read \`~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md\` (mission lifecycle protocol)
2. Read \`docs/MISSION-MANIFEST.md\` for full mission scope, milestones, and success criteria
3. Read the latest scratchpad in \`docs/scratchpads/\` for session history, decisions, and corrections
4. Read \`docs/TASKS.md\` for current task state (what is done, what is next)
5. After reading all four, acknowledge the mission state to the user before proceeding
If the user gives a task, execute it within the mission context. If no task is given, present mission status and ask how to proceed.
`;
}
// ─── PRD status ──────────────────────────────────────────────────────────────
function buildPrdBlock(): string {
const prdFile = 'docs/PRD.md';
if (!existsSync(prdFile)) return '';
const content = readFileSync(prdFile, 'utf-8');
const patterns = [
/^#{2,3} .*(problem statement|objective)/im,
/^#{2,3} .*(scope|non.goal|out of scope|in.scope)/im,
/^#{2,3} .*(user stor|stakeholder|user.*requirement)/im,
/^#{2,3} .*functional requirement/im,
/^#{2,3} .*non.functional/im,
/^#{2,3} .*acceptance criteria/im,
/^#{2,3} .*(technical consideration|constraint|dependenc)/im,
/^#{2,3} .*(risk|open question)/im,
/^#{2,3} .*(success metric|test|verification)/im,
/^#{2,3} .*(milestone|delivery|scope version)/im,
];
let sections = 0;
for (const pattern of patterns) {
if (pattern.test(content)) sections++;
}
const assumptions = (content.match(/ASSUMPTION:/g) ?? []).length;
const status = sections < 10 ? `incomplete (${sections}/10 sections)` : 'ready';
return `
# PRD Status
- **File:** docs/PRD.md
- **Status:** ${status}
- **Assumptions:** ${assumptions}
`;
}
// ─── Runtime prompt builder ──────────────────────────────────────────────────
function buildRuntimePrompt(runtime: RuntimeName): string {
const runtimeContractPaths: Record<RuntimeName, string> = {
claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'),
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'),
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'),
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'),
};
const runtimeFile = runtimeContractPaths[runtime];
checkFile(runtimeFile, `Runtime contract for ${runtime}`);
const parts: string[] = [];
// Mission context (injected first)
const mission = detectMission();
if (mission) {
parts.push(buildMissionBlock(mission));
}
// PRD status
const prdBlock = buildPrdBlock();
if (prdBlock) parts.push(prdBlock);
// Hard gate
parts.push(`# Mosaic Launcher Runtime Contract (Hard Gate)
This contract is injected by \`mosaic\` launch and is mandatory.
First assistant response MUST start with exactly one mode declaration line:
1. Orchestration mission: \`Now initiating Orchestrator mode...\`
2. Implementation mission: \`Now initiating Delivery mode...\`
3. Review-only mission: \`Now initiating Review mode...\`
No tool call or implementation step may occur before that first line.
Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations.
For required push/merge/issue-close/release actions, execute without routine confirmation prompts.
`);
// AGENTS.md
parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8'));
// USER.md
const user = readOptional(join(MOSAIC_HOME, 'USER.md'));
if (user) parts.push('\n\n# User Profile\n\n' + user);
// TOOLS.md
const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md'));
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
// Runtime-specific contract
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
return parts.join('\n');
}
// ─── Session lock ────────────────────────────────────────────────────────────
function writeSessionLock(runtime: string): void {
const missionFile = '.mosaic/orchestrator/mission.json';
const lockFile = '.mosaic/orchestrator/session.lock';
const data = readJson(missionFile);
if (!data) return;
const status = String(data['status'] ?? 'inactive');
if (status !== 'active' && status !== 'paused') return;
const sessionId = `${runtime}-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
const lock = {
session_id: sessionId,
runtime,
pid: process.pid,
started_at: new Date().toISOString(),
project_path: process.cwd(),
milestone_id: '',
};
try {
mkdirSync(dirname(lockFile), { recursive: true });
writeFileSync(lockFile, JSON.stringify(lock, null, 2) + '\n');
// Clean up on exit
const cleanup = () => {
try {
rmSync(lockFile, { force: true });
} catch {
// best-effort
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => {
cleanup();
process.exit(130);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(143);
});
} catch {
// Non-fatal
}
}
// ─── Resumable session advisory ──────────────────────────────────────────────
function checkResumableSession(): void {
const lockFile = '.mosaic/orchestrator/session.lock';
const missionFile = '.mosaic/orchestrator/mission.json';
if (existsSync(lockFile)) {
const lock = readJson(lockFile);
if (lock) {
const pid = Number(lock['pid'] ?? 0);
if (pid > 0) {
try {
process.kill(pid, 0); // Check if alive
} catch {
// Process is dead — stale lock
rmSync(lockFile, { force: true });
console.log(`[mosaic] Cleaned up stale session lock (PID ${pid} no longer running).\n`);
}
}
}
} else if (existsSync(missionFile)) {
const data = readJson(missionFile);
if (data && data['status'] === 'active') {
console.log('[mosaic] Active mission detected. Generate continuation prompt with:');
console.log('[mosaic] mosaic coord continue\n');
}
}
}
// ─── Write config for runtimes that read from fixed paths ────────────────────
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
const prompt = buildRuntimePrompt(runtime);
mkdirSync(dirname(destPath), { recursive: true });
const existing = readOptional(destPath);
if (existing !== prompt) {
writeFileSync(destPath, prompt);
}
}
// ─── Pi skill/extension discovery ────────────────────────────────────────────
function discoverPiSkills(): string[] {
const args: string[] = [];
for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) {
if (!existsSync(skillsRoot)) continue;
try {
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillDir = join(skillsRoot, entry.name);
if (existsSync(join(skillDir, 'SKILL.md'))) {
args.push('--skill', skillDir);
}
}
} catch {
// skip
}
}
return args;
}
function discoverPiExtension(): string[] {
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
return existsSync(ext) ? ['--extension', ext] : [];
}
// ─── Launch functions ────────────────────────────────────────────────────────
function getMissionPrompt(): string {
const mission = detectMission();
if (!mission) return '';
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
}
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never {
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul();
checkRuntime(runtime);
// Pi doesn't need sequential-thinking (has native thinking levels)
if (runtime !== 'pi') {
checkSequentialThinking(runtime);
}
checkResumableSession();
const missionPrompt = getMissionPrompt();
const hasMissionNoArgs = missionPrompt && args.length === 0;
const label = RUNTIME_LABELS[runtime];
const modeStr = yolo ? ' in YOLO mode' : '';
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
writeSessionLock(runtime);
switch (runtime) {
case 'claude': {
const prompt = buildRuntimePrompt('claude');
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
cliArgs.push('--append-system-prompt', prompt);
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
} else {
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('claude', cliArgs);
break;
}
case 'codex': {
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
} else {
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('codex', cliArgs);
break;
}
case 'opencode': {
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
console.log(`[mosaic] Launching ${label}${modeStr}...`);
execRuntime('opencode', args);
break;
}
case 'pi': {
const prompt = buildRuntimePrompt('pi');
const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...discoverPiSkills());
cliArgs.push(...discoverPiExtension());
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
} else {
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('pi', cliArgs);
break;
}
}
process.exit(0); // Unreachable but satisfies never
}
/** exec into the runtime, replacing the current process. */
function execRuntime(cmd: string, args: string[]): void {
try {
// Use execFileSync with inherited stdio to replace the process
const result = spawnSync(cmd, args, {
stdio: 'inherit',
env: process.env,
});
process.exit(result.status ?? 0);
} catch (err) {
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
process.exit(1);
}
}
// ─── Framework script/tool delegation ───────────────────────────────────────
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
if (!existsSync(scriptPath)) {
console.error(`[mosaic] Script not found: ${scriptPath}`);
process.exit(1);
}
try {
execFileSync('bash', [scriptPath, ...args], {
stdio: 'inherit',
env: { ...process.env, ...env },
});
process.exit(0);
} catch (err) {
process.exit((err as { status?: number }).status ?? 1);
}
}
/**
* Resolve a path under the framework tools directory. Prefers the version
* bundled in the @mosaic/mosaic npm package (always matches the installed
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
*/
function resolveTool(...segments: string[]): string {
// Resolve relative to the built file: dist/commands/launch.js → ../../framework/tools/...
const thisFile = fileURLToPath(import.meta.url);
const bundled = join(dirname(thisFile), '..', '..', 'framework', 'tools', ...segments);
if (existsSync(bundled)) return bundled;
return join(MOSAIC_HOME, 'tools', ...segments);
}
function fwScript(name: string): string {
return resolveTool('_scripts', name);
}
function toolScript(toolDir: string, name: string): string {
return resolveTool(toolDir, name);
}
// ─── Coord (mission orchestrator) ───────────────────────────────────────────
const COORD_SUBCMDS: Record<string, string> = {
status: 'session-status.sh',
session: 'session-status.sh',
init: 'mission-init.sh',
mission: 'mission-status.sh',
progress: 'mission-status.sh',
continue: 'continue-prompt.sh',
next: 'continue-prompt.sh',
run: 'session-run.sh',
start: 'session-run.sh',
smoke: 'smoke-test.sh',
test: 'smoke-test.sh',
resume: 'session-resume.sh',
recover: 'session-resume.sh',
};
function runCoord(args: string[]): never {
checkMosaicHome();
let runtime = 'claude';
let yoloFlag = '';
const coordArgs: string[] = [];
for (const arg of args) {
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
runtime = arg.slice(2);
} else if (arg === '--yolo') {
yoloFlag = '--yolo';
} else {
coordArgs.push(arg);
}
}
const subcmd = coordArgs[0] ?? 'help';
const subArgs = coordArgs.slice(1);
const script = COORD_SUBCMDS[subcmd];
if (!script) {
console.log(`mosaic coord — mission coordinator tools
Commands:
init --name <name> [opts] Initialize a new mission
mission [--project <path>] Show mission progress dashboard
status [--project <path>] Check agent session health
continue [--project <path>] Generate continuation prompt
run [--project <path>] Launch runtime with mission context
smoke Run orchestration smoke checks
resume [--project <path>] Crash recovery
Runtime: --claude (default) | --codex | --pi | --yolo`);
process.exit(subcmd === 'help' ? 0 : 1);
}
if (yoloFlag) subArgs.unshift(yoloFlag);
delegateToScript(toolScript('orchestrator', script), subArgs, {
MOSAIC_COORD_RUNTIME: runtime,
});
}
// ─── Prdy (PRD tools via framework scripts) ─────────────────────────────────
const PRDY_SUBCMDS: Record<string, string> = {
init: 'prdy-init.sh',
update: 'prdy-update.sh',
validate: 'prdy-validate.sh',
check: 'prdy-validate.sh',
status: 'prdy-status.sh',
};
function runPrdyLocal(args: string[]): never {
checkMosaicHome();
let runtime = 'claude';
const prdyArgs: string[] = [];
for (const arg of args) {
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
runtime = arg.slice(2);
} else {
prdyArgs.push(arg);
}
}
const subcmd = prdyArgs[0] ?? 'help';
const subArgs = prdyArgs.slice(1);
const script = PRDY_SUBCMDS[subcmd];
if (!script) {
console.log(`mosaic prdy — PRD creation and validation
Commands:
init [--project <path>] [--name <feature>] Create docs/PRD.md
update [--project <path>] Update existing PRD
validate [--project <path>] Check PRD completeness
status [--project <path>] Quick PRD health check
Runtime: --claude (default) | --codex | --pi`);
process.exit(subcmd === 'help' ? 0 : 1);
}
delegateToScript(toolScript('prdy', script), subArgs, {
MOSAIC_PRDY_RUNTIME: runtime,
});
}
// ─── Seq (sequential-thinking MCP) ──────────────────────────────────────────
function runSeq(args: string[]): never {
checkMosaicHome();
const action = args[0] ?? 'check';
const rest = args.slice(1);
const checker = fwScript('mosaic-ensure-sequential-thinking');
switch (action) {
case 'check':
delegateToScript(checker, ['--check', ...rest]);
break; // unreachable
case 'fix':
case 'apply':
delegateToScript(checker, rest);
break;
case 'start': {
console.log('[mosaic] Starting sequential-thinking MCP server...');
try {
execFileSync('npx', ['-y', '@modelcontextprotocol/server-sequential-thinking', ...rest], {
stdio: 'inherit',
});
process.exit(0);
} catch (err) {
process.exit((err as { status?: number }).status ?? 1);
}
break;
}
default:
console.error(`[mosaic] Unknown seq subcommand '${action}'. Use: check|fix|start`);
process.exit(1);
}
}
// ─── Upgrade ────────────────────────────────────────────────────────────────
function runUpgrade(args: string[]): never {
checkMosaicHome();
const subcmd = args[0];
if (!subcmd || subcmd === 'release') {
delegateToScript(fwScript('mosaic-release-upgrade'), args.slice(subcmd === 'release' ? 1 : 0));
} else if (subcmd === 'check') {
delegateToScript(fwScript('mosaic-release-upgrade'), ['--dry-run', ...args.slice(1)]);
} else if (subcmd === 'project') {
delegateToScript(fwScript('mosaic-upgrade'), args.slice(1));
} else if (subcmd.startsWith('-')) {
delegateToScript(fwScript('mosaic-release-upgrade'), args);
} else {
delegateToScript(fwScript('mosaic-upgrade'), args);
}
}
// ─── Commander registration ─────────────────────────────────────────────────
export function registerLaunchCommands(program: Command): void {
// Runtime launchers
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
program
.command(runtime)
.description(`Launch ${RUNTIME_LABELS[runtime]} with Mosaic injection`)
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
launchRuntime(runtime, cmd.args, false);
});
}
// Yolo mode
program
.command('yolo <runtime>')
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((runtime: string, _opts: unknown, cmd: Command) => {
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
if (!valid.includes(runtime as RuntimeName)) {
console.error(
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`,
);
process.exit(1);
}
launchRuntime(runtime as RuntimeName, cmd.args, true);
});
// Coord (mission orchestrator)
program
.command('coord')
.description('Mission coordinator tools (init, status, run, continue, resume)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runCoord(cmd.args);
});
// Prdy (PRD tools via local framework scripts)
program
.command('prdy')
.description('PRD creation and validation (init, update, validate, status)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runPrdyLocal(cmd.args);
});
// Seq (sequential-thinking MCP management)
program
.command('seq')
.description('sequential-thinking MCP management (check/fix/start)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runSeq(cmd.args);
});
// Upgrade (release + project)
program
.command('upgrade')
.description('Upgrade Mosaic release or project files')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runUpgrade(cmd.args);
});
// Direct framework script delegates
const directCommands: Record<string, { desc: string; script: string }> = {
init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' },
doctor: { desc: 'Health audit — detect drift and missing files', script: 'mosaic-doctor' },
sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' },
bootstrap: {
desc: 'Bootstrap a repo with Mosaic standards',
script: 'mosaic-bootstrap-repo',
},
};
for (const [name, { desc, script }] of Object.entries(directCommands)) {
program
.command(name)
.description(desc)
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
checkMosaicHome();
delegateToScript(fwScript(script), cmd.args);
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { readFileSync, existsSync } from 'node:fs';
import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';
import type { ConfigService } from './config-service.js';
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
@@ -140,6 +140,23 @@ export class FileConfigAdapter implements ConfigService {
preserve: preservePaths,
excludeGit: true,
});
// Copy default root-level .md files (AGENTS.md, STANDARDS.md, etc.)
// from framework/defaults/ into mosaicHome root if they don't exist yet.
// These are framework contracts — only written on first install, never
// overwritten (user may have customized them).
const defaultsDir = join(this.sourceDir, 'defaults');
if (existsSync(defaultsDir)) {
for (const entry of readdirSync(defaultsDir)) {
const dest = join(this.mosaicHome, entry);
if (!existsSync(dest)) {
const src = join(defaultsDir, entry);
if (statSync(src).isFile()) {
copyFileSync(src, dest);
}
}
}
}
}
/**

View File

@@ -1,93 +1 @@
#!/usr/bin/env node
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command } from 'commander';
import { ClackPrompter } from './prompter/clack-prompter.js';
import { HeadlessPrompter } from './prompter/headless-prompter.js';
import { createConfigService } from './config/config-service.js';
import { runWizard } from './wizard.js';
import { WizardCancelledError } from './errors.js';
import { VERSION, DEFAULT_MOSAIC_HOME } from './constants.js';
import type { CommunicationStyle } from './types.js';
export { VERSION, DEFAULT_MOSAIC_HOME };
export { runWizard } from './wizard.js';
export {
checkForUpdate,
backgroundUpdateCheck,
formatUpdateNotice,
getInstalledVersion,
getLatestVersion,
semverLt,
} from './runtime/update-checker.js';
export type { UpdateCheckResult } from './runtime/update-checker.js';
export { ClackPrompter } from './prompter/clack-prompter.js';
export { HeadlessPrompter } from './prompter/headless-prompter.js';
export { createConfigService } from './config/config-service.js';
export { WizardCancelledError } from './errors.js';
const program = new Command()
.name('mosaic-wizard')
.description('Mosaic Installation Wizard')
.version(VERSION);
program
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
.option('--source-dir <path>', 'Source directory for framework files')
.option('--mosaic-home <path>', 'Target config directory', DEFAULT_MOSAIC_HOME)
// SOUL.md overrides
.option('--name <name>', 'Agent name')
.option('--role <description>', 'Agent role description')
.option('--style <style>', 'Communication style: direct|friendly|formal')
.option('--accessibility <prefs>', 'Accessibility preferences')
.option('--guardrails <rules>', 'Custom guardrails')
// USER.md overrides
.option('--user-name <name>', 'Your name')
.option('--pronouns <pronouns>', 'Your pronouns')
.option('--timezone <tz>', 'Your timezone')
.action(async (opts: Record<string, string | boolean | undefined>) => {
try {
const mosaicHome = (opts['mosaicHome'] as string) ?? DEFAULT_MOSAIC_HOME;
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
const configService = createConfigService(mosaicHome, sourceDir);
const style = opts['style'] as CommunicationStyle | undefined;
await runWizard({
mosaicHome,
sourceDir,
prompter,
configService,
cliOverrides: {
soul: {
agentName: opts['name'] as string | undefined,
roleDescription: opts['role'] as string | undefined,
communicationStyle: style,
accessibility: opts['accessibility'] as string | undefined,
customGuardrails: opts['guardrails'] as string | undefined,
},
user: {
userName: opts['userName'] as string | undefined,
pronouns: opts['pronouns'] as string | undefined,
timezone: opts['timezone'] as string | undefined,
},
},
});
} catch (err) {
if (err instanceof WizardCancelledError) {
console.log('\nWizard cancelled.');
process.exit(0);
}
console.error('Wizard failed:', err);
process.exit(1);
}
});
const entryPath = process.argv[1] ? resolve(process.argv[1]) : '';
if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) {
program.parse();
}
export const VERSION = '0.0.0';

View File

@@ -122,10 +122,18 @@ export function semverLt(a: string, b: string): boolean {
// ─── Cache ──────────────────────────────────────────────────────────────────
function readCache(): UpdateCheckResult | null {
/** Cache stores only the latest registry version (the expensive network call).
* The installed version is always checked fresh — it's a local `npm ls`. */
interface RegistryCache {
latest: string;
checkedAt: string;
registry: string;
}
function readCache(): RegistryCache | null {
try {
if (!existsSync(CACHE_FILE)) return null;
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as UpdateCheckResult;
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as RegistryCache;
const age = Date.now() - new Date(raw.checkedAt).getTime();
if (age > CACHE_TTL_MS) return null;
return raw;
@@ -134,10 +142,10 @@ function readCache(): UpdateCheckResult | null {
}
}
function writeCache(result: UpdateCheckResult): void {
function writeCache(entry: RegistryCache): void {
try {
mkdirSync(CACHE_DIR, { recursive: true });
writeFileSync(CACHE_FILE, JSON.stringify(result, null, 2) + '\n', 'utf-8');
writeFileSync(CACHE_FILE, JSON.stringify(entry, null, 2) + '\n', 'utf-8');
} catch {
// Best-effort — cache is not critical
}
@@ -174,29 +182,40 @@ export function getLatestVersion(): string {
}
/**
* Perform an update check — uses cache when fresh, otherwise hits registry.
* Perform an update check — uses registry cache when fresh, always checks
* installed version fresh (local npm ls is cheap, caching it causes stale
* "update available" banners after an upgrade).
* Never throws.
*/
export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult {
const current = getInstalledVersion();
let latest: string;
let checkedAt: string;
if (!options?.skipCache) {
const cached = readCache();
if (cached) return cached;
if (cached) {
latest = cached.latest;
checkedAt = cached.checkedAt;
} else {
latest = getLatestVersion();
checkedAt = new Date().toISOString();
writeCache({ latest, checkedAt, registry: REGISTRY });
}
} else {
latest = getLatestVersion();
checkedAt = new Date().toISOString();
writeCache({ latest, checkedAt, registry: REGISTRY });
}
const current = getInstalledVersion();
const latest = getLatestVersion();
const updateAvailable = !!(current && latest && semverLt(current, latest));
const result: UpdateCheckResult = {
return {
current,
latest,
updateAvailable,
checkedAt: new Date().toISOString(),
updateAvailable: !!(current && latest && semverLt(current, latest)),
checkedAt,
registry: REGISTRY,
};
writeCache(result);
return result;
}
/**

View File

@@ -0,0 +1,468 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Box, useApp, useInput } from 'ink';
import type { ParsedCommand } from '@mosaic/types';
import { TopBar } from './components/top-bar.js';
import { BottomBar } from './components/bottom-bar.js';
import { MessageList } from './components/message-list.js';
import { InputBar } from './components/input-bar.js';
import { Sidebar } from './components/sidebar.js';
import { SearchBar } from './components/search-bar.js';
import { useSocket } from './hooks/use-socket.js';
import { useGitInfo } from './hooks/use-git-info.js';
import { useViewport } from './hooks/use-viewport.js';
import { useAppMode } from './hooks/use-app-mode.js';
import { useConversations } from './hooks/use-conversations.js';
import { useSearch } from './hooks/use-search.js';
import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js';
import { fetchConversationMessages } from './gateway-api.js';
import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js';
export interface TuiAppProps {
gatewayUrl: string;
conversationId?: string;
sessionCookie?: string;
initialModel?: string;
initialProvider?: string;
agentId?: string;
agentName?: string;
projectId?: string;
/** CLI package version passed from the entry point (cli.ts). */
version?: string;
}
export function TuiApp({
gatewayUrl,
conversationId,
sessionCookie,
initialModel,
initialProvider,
agentId,
agentName,
projectId: _projectId,
version = '0.0.0',
}: TuiAppProps) {
const { exit } = useApp();
const gitInfo = useGitInfo();
const appMode = useAppMode();
const socket = useSocket({
gatewayUrl,
sessionCookie,
initialConversationId: conversationId,
initialModel,
initialProvider,
agentId,
});
const conversations = useConversations({ gatewayUrl, sessionCookie });
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(() => {
if (currentMatch && appMode.mode === 'search') {
viewport.scrollTo(currentMatch.messageIndex);
}
}, [currentMatch, appMode.mode, viewport]);
// Compute highlighted message indices for MessageList
const highlightedMessageIndices = useMemo(() => {
if (search.matches.length === 0) return undefined;
return new Set(search.matches.map((m) => m.messageIndex));
}, [search.matches]);
const currentHighlightIndex = currentMatch?.messageIndex;
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
// Controlled input state — held here so Ctrl+C can clear it
const [tuiInput, setTuiInput] = useState('');
// Ctrl+C double-press: first press with empty input shows hint; second exits
const ctrlCPendingExit = useRef(false);
// Flag to suppress the character that ink-text-input leaks when a Ctrl+key
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
const ctrlJustFired = useRef(false);
// Wrap sendMessage to expand @file references before sending
const sendMessageWithFileRefs = useCallback(
(content: string) => {
if (!hasFileRefs(content)) {
socket.sendMessage(content);
return;
}
void expandFileRefs(content)
.then(({ expandedMessage, filesAttached, errors }) => {
for (const err of errors) {
socket.addSystemMessage(err);
}
if (filesAttached.length > 0) {
socket.addSystemMessage(
`📎 Attached ${filesAttached.length} file(s): ${filesAttached.join(', ')}`,
);
}
socket.sendMessage(expandedMessage);
})
.catch((err: unknown) => {
socket.addSystemMessage(
`File expansion failed: ${err instanceof Error ? err.message : String(err)}`,
);
// Send original message without expansion
socket.sendMessage(content);
});
},
[socket],
);
const handleLocalCommand = useCallback(
(parsed: ParsedCommand) => {
switch (parsed.command) {
case 'help':
case 'h': {
const result = executeHelp(parsed);
socket.addSystemMessage(result);
break;
}
case 'status':
case 's': {
const result = executeStatus(parsed, {
connected: socket.connected,
model: socket.modelName,
provider: socket.providerName,
sessionId: socket.conversationId ?? null,
tokenCount: socket.tokenUsage.total,
});
socket.addSystemMessage(result);
break;
}
case 'clear':
socket.clearMessages();
break;
case 'new':
case 'n':
void conversations
.createConversation()
.then((conv) => {
if (conv) {
socket.switchConversation(conv.id);
appMode.setMode('chat');
}
})
.catch(() => {
socket.addSystemMessage('Failed to create new conversation.');
});
break;
case 'attach': {
if (!parsed.args) {
socket.addSystemMessage('Usage: /attach <file-path>');
break;
}
void handleAttachCommand(parsed.args)
.then(({ content, error }) => {
if (error) {
socket.addSystemMessage(`Attach error: ${error}`);
} else if (content) {
// Send the file content as a user message
socket.sendMessage(content);
}
})
.catch((err: unknown) => {
socket.addSystemMessage(
`Attach failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
break;
}
case 'stop':
if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) {
socket.socketRef.current.emit('abort', {
conversationId: socket.conversationId,
});
socket.addSystemMessage('Abort signal sent.');
} else {
socket.addSystemMessage('No active stream to stop.');
}
break;
case 'cost': {
const u = socket.tokenUsage;
socket.addSystemMessage(
`Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`,
);
break;
}
case 'history':
case 'hist': {
void executeHistory({
conversationId: socket.conversationId,
gatewayUrl,
sessionCookie,
fetchMessages: fetchConversationMessages,
})
.then((result) => {
socket.addSystemMessage(result);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
socket.addSystemMessage(`Failed to fetch history: ${msg}`);
});
break;
}
default:
socket.addSystemMessage(`Local command not implemented: /${parsed.command}`);
}
},
[socket],
);
const handleGatewayCommand = useCallback(
(parsed: ParsedCommand) => {
if (!socket.socketRef.current?.connected) {
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
return;
}
socket.socketRef.current.emit('command:execute', {
conversationId: socket.conversationId ?? '',
command: parsed.command,
args: parsed.args ?? undefined,
});
},
[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) => {
// Ctrl+C: clear input → show hint → second empty press exits
if (key.ctrl && ch === 'c') {
if (tuiInput) {
setTuiInput('');
ctrlCPendingExit.current = false;
} else if (ctrlCPendingExit.current) {
exit();
} else {
ctrlCPendingExit.current = true;
socket.addSystemMessage('Press Ctrl+C again to exit.');
}
return;
}
// Any other key resets the pending-exit flag
ctrlCPendingExit.current = false;
// Ctrl+L: toggle sidebar (refresh on open)
if (key.ctrl && ch === 'l') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
const willOpen = !appMode.sidebarOpen;
appMode.toggleSidebar();
if (willOpen) {
void conversations.refresh();
}
return;
}
// Ctrl+N: create new conversation and switch to it
if (key.ctrl && ch === 'n') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
void conversations
.createConversation()
.then((conv) => {
if (conv) {
socket.switchConversation(conv.id);
appMode.setMode('chat');
}
})
.catch(() => {});
return;
}
// Ctrl+K: toggle search mode
if (key.ctrl && ch === 'k') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
if (appMode.mode === 'search') {
search.clear();
appMode.setMode('chat');
} else {
appMode.setMode('search');
}
return;
}
// 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') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
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);
}
}
return;
}
// 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 inputPlaceholder =
appMode.mode === 'sidebar'
? 'focus is on sidebar… press Esc to return'
: appMode.mode === 'search'
? 'search mode… press Esc to return'
: undefined;
const isSearchMode = appMode.mode === 'search';
const messageArea = (
<Box flexDirection="column" flexGrow={1}>
<MessageList
messages={socket.messages}
isStreaming={socket.isStreaming}
currentStreamText={socket.currentStreamText}
currentThinkingText={socket.currentThinkingText}
activeToolCalls={socket.activeToolCalls}
scrollOffset={viewport.scrollOffset}
viewportSize={viewport.viewportSize}
isScrolledUp={viewport.isScrolledUp}
highlightedMessageIndices={highlightedMessageIndices}
currentHighlightIndex={currentHighlightIndex}
/>
{isSearchMode && (
<SearchBar
query={search.query}
onQueryChange={search.setQuery}
totalMatches={search.totalMatches}
currentMatch={search.currentMatchIndex}
onNext={search.nextMatch}
onPrev={search.prevMatch}
onClose={() => {
search.clear();
appMode.setMode('chat');
}}
focused={isSearchMode}
/>
)}
<InputBar
value={tuiInput}
onChange={(val: string) => {
// Suppress the character that ink-text-input leaks when a Ctrl+key
// combo fires (e.g. Ctrl+T inserts 't'). The ctrlJustFired ref is
// set synchronously in the useInput handler and cleared via a
// microtask, so this callback sees it as still true on the same
// event-loop tick.
if (ctrlJustFired.current) {
ctrlJustFired.current = false;
return;
}
setTuiInput(val);
}}
onSubmit={sendMessageWithFileRefs}
onSystemMessage={socket.addSystemMessage}
onLocalCommand={handleLocalCommand}
onGatewayCommand={handleGatewayCommand}
isStreaming={socket.isStreaming}
connected={socket.connected}
focused={appMode.mode === 'chat'}
placeholder={inputPlaceholder}
allCommands={commandRegistry.getAll()}
/>
</Box>
);
return (
<Box flexDirection="column" height="100%">
<Box marginTop={1} />
<TopBar
gatewayUrl={gatewayUrl}
version={version}
modelName={socket.modelName}
thinkingLevel={socket.thinkingLevel}
contextWindow={socket.tokenUsage.contextWindow}
agentName={agentName ?? 'default'}
connected={socket.connected}
connecting={socket.connecting}
/>
{appMode.sidebarOpen ? (
<Box flexDirection="row" flexGrow={1}>
<Sidebar
conversations={conversations.conversations}
activeConversationId={socket.conversationId}
selectedIndex={sidebarSelectedIndex}
onSelectIndex={setSidebarSelectedIndex}
onSwitchConversation={handleSwitchConversation}
onDeleteConversation={handleDeleteConversation}
loading={conversations.loading}
focused={appMode.mode === 'sidebar'}
width={30}
/>
{messageArea}
</Box>
) : (
<Box flexGrow={1}>{messageArea}</Box>
)}
<BottomBar
gitInfo={gitInfo}
tokenUsage={socket.tokenUsage}
connected={socket.connected}
connecting={socket.connecting}
modelName={socket.modelName}
providerName={socket.providerName}
thinkingLevel={socket.thinkingLevel}
conversationId={socket.conversationId}
routingDecision={socket.routingDecision}
/>
</Box>
);
}

View File

@@ -0,0 +1,348 @@
/**
* Integration tests for TUI command parsing + registry (P8-019)
*
* Covers:
* - parseSlashCommand() + commandRegistry.find() round-trip for all aliases
* - /help, /stop, /cost, /status resolve to 'local' execution
* - Unknown commands return null from find()
* - Alias resolution: /h → help, /m → model, /n → new, etc.
* - filterCommands prefix filtering
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { parseSlashCommand } from './parse.js';
import { CommandRegistry } from './registry.js';
import type { CommandDef } from '@mosaic/types';
// ─── Parse + Registry Round-trip ─────────────────────────────────────────────
describe('parseSlashCommand + CommandRegistry — integration', () => {
let registry: CommandRegistry;
// Gateway-style commands to simulate a live manifest
const gatewayCommands: CommandDef[] = [
{
name: 'model',
description: 'Switch the active model',
aliases: ['m'],
args: [{ name: 'model-name', type: 'string', optional: false, description: 'Model name' }],
scope: 'core',
execution: 'socket',
available: true,
},
{
name: 'thinking',
description: 'Set thinking level',
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: '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: 'preferences',
description: 'View or set user preferences',
aliases: ['pref'],
args: [
{
name: 'action',
type: 'enum',
optional: true,
values: ['show', 'set', 'reset'],
description: 'Action',
},
],
scope: 'core',
execution: 'rest',
available: true,
},
{
name: 'gc',
description: 'Trigger garbage collection sweep',
aliases: [],
scope: 'core',
execution: 'socket',
available: true,
},
{
name: 'mission',
description: 'View or set active mission',
aliases: [],
args: [{ name: 'args', type: 'string', optional: true, description: 'status | set <id>' }],
scope: 'agent',
execution: 'socket',
available: true,
},
];
beforeEach(() => {
registry = new CommandRegistry();
registry.updateManifest({ version: 1, commands: gatewayCommands, skills: [] });
});
// ── parseSlashCommand tests ──
it('returns null for non-slash input', () => {
expect(parseSlashCommand('hello world')).toBeNull();
expect(parseSlashCommand('')).toBeNull();
expect(parseSlashCommand('model')).toBeNull();
});
it('parses "/model claude-3-opus" → command=model args=claude-3-opus', () => {
const parsed = parseSlashCommand('/model claude-3-opus');
expect(parsed).not.toBeNull();
expect(parsed!.command).toBe('model');
expect(parsed!.args).toBe('claude-3-opus');
expect(parsed!.raw).toBe('/model claude-3-opus');
});
it('parses "/gc" with no args → command=gc args=null', () => {
const parsed = parseSlashCommand('/gc');
expect(parsed).not.toBeNull();
expect(parsed!.command).toBe('gc');
expect(parsed!.args).toBeNull();
});
it('parses "/system you are a helpful assistant" → args contains full text', () => {
const parsed = parseSlashCommand('/system you are a helpful assistant');
expect(parsed!.command).toBe('system');
expect(parsed!.args).toBe('you are a helpful assistant');
});
it('parses "/help" → command=help args=null', () => {
const parsed = parseSlashCommand('/help');
expect(parsed!.command).toBe('help');
expect(parsed!.args).toBeNull();
});
// ── Round-trip: parse then find ──
it('round-trip: /m → resolves to "model" command via alias', () => {
const parsed = parseSlashCommand('/m claude-3-haiku');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
// /m → model (alias map in registry)
expect(cmd!.name === 'model' || cmd!.aliases.includes('m')).toBe(true);
});
it('round-trip: /h → resolves to "help" (local command)', () => {
const parsed = parseSlashCommand('/h');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
expect(cmd!.name === 'help' || cmd!.aliases.includes('h')).toBe(true);
});
it('round-trip: /n → resolves to "new" via gateway manifest', () => {
const parsed = parseSlashCommand('/n');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
expect(cmd!.name === 'new' || cmd!.aliases.includes('n')).toBe(true);
});
it('round-trip: /a → resolves to "agent" via gateway manifest', () => {
const parsed = parseSlashCommand('/a list');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
expect(cmd!.name === 'agent' || cmd!.aliases.includes('a')).toBe(true);
});
it('round-trip: /pref → resolves to "preferences" via alias', () => {
const parsed = parseSlashCommand('/pref show');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
expect(cmd!.name === 'preferences' || cmd!.aliases.includes('pref')).toBe(true);
});
it('round-trip: /t → resolves to "thinking" via alias', () => {
const parsed = parseSlashCommand('/t high');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
expect(cmd!.name === 'thinking' || cmd!.aliases.includes('t')).toBe(true);
});
// ── Local commands resolve to 'local' execution ──
it('/help resolves to local execution', () => {
const cmd = registry.find('help');
expect(cmd).not.toBeNull();
expect(cmd!.execution).toBe('local');
});
it('/stop resolves to local execution', () => {
const cmd = registry.find('stop');
expect(cmd).not.toBeNull();
expect(cmd!.execution).toBe('local');
});
it('/cost resolves to local execution', () => {
const cmd = registry.find('cost');
expect(cmd).not.toBeNull();
expect(cmd!.execution).toBe('local');
});
it('/status resolves to local execution (TUI local override)', () => {
const cmd = registry.find('status');
expect(cmd).not.toBeNull();
// status is 'local' in the TUI registry (local takes precedence over gateway)
expect(cmd!.execution).toBe('local');
});
// ── Unknown commands return null ──
it('find() returns null for unknown command', () => {
expect(registry.find('nonexistent')).toBeNull();
expect(registry.find('xyz')).toBeNull();
expect(registry.find('')).toBeNull();
});
it('find() returns null when no gateway manifest and command not local', () => {
const emptyRegistry = new CommandRegistry();
expect(emptyRegistry.find('model')).toBeNull();
expect(emptyRegistry.find('gc')).toBeNull();
});
// ── getAll returns combined local + gateway ──
it('getAll() includes both local and gateway commands', () => {
const all = registry.getAll();
const names = all.map((c) => c.name);
// Local commands
expect(names).toContain('help');
expect(names).toContain('stop');
expect(names).toContain('cost');
expect(names).toContain('status');
// Gateway commands
expect(names).toContain('model');
expect(names).toContain('gc');
});
it('getLocalCommands() returns only local commands', () => {
const local = registry.getLocalCommands();
expect(local.every((c) => c.execution === 'local')).toBe(true);
expect(local.some((c) => c.name === 'help')).toBe(true);
expect(local.some((c) => c.name === 'stop')).toBe(true);
});
});
// ─── filterCommands (autocomplete) ────────────────────────────────────────────
describe('filterCommands (from CommandAutocomplete)', () => {
// Import inline since filterCommands is not exported — replicate the logic here
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
if (!query) return commands;
const q = query.toLowerCase();
return commands.filter(
(c) =>
c.name.includes(q) ||
c.aliases.some((a) => a.includes(q)) ||
c.description.toLowerCase().includes(q),
);
}
const commands: CommandDef[] = [
{
name: 'model',
description: 'Switch the active model',
aliases: ['m'],
scope: 'core',
execution: 'socket',
available: true,
},
{
name: 'mission',
description: 'View or set active mission',
aliases: [],
scope: 'agent',
execution: 'socket',
available: true,
},
{
name: 'help',
description: 'Show available commands',
aliases: ['h'],
scope: 'core',
execution: 'local',
available: true,
},
{
name: 'gc',
description: 'Trigger garbage collection sweep',
aliases: [],
scope: 'core',
execution: 'socket',
available: true,
},
];
it('returns all commands when query is empty', () => {
expect(filterCommands(commands, '')).toHaveLength(commands.length);
});
it('filters by name prefix "mi" → mission only (not model, as "mi" not in model name or aliases)', () => {
const result = filterCommands(commands, 'mi');
const names = result.map((c) => c.name);
expect(names).toContain('mission');
expect(names).not.toContain('gc');
});
it('filters by name prefix "mo" → model only', () => {
const result = filterCommands(commands, 'mo');
const names = result.map((c) => c.name);
expect(names).toContain('model');
expect(names).not.toContain('mission');
expect(names).not.toContain('gc');
});
it('filters by exact name "gc" → gc only', () => {
const result = filterCommands(commands, 'gc');
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('gc');
});
it('filters by alias "h" → help', () => {
const result = filterCommands(commands, 'h');
const names = result.map((c) => c.name);
expect(names).toContain('help');
});
it('filters by description keyword "switch" → model', () => {
const result = filterCommands(commands, 'switch');
const names = result.map((c) => c.name);
expect(names).toContain('model');
});
it('returns empty array when no commands match', () => {
const result = filterCommands(commands, 'zzznotfound');
expect(result).toHaveLength(0);
});
});

View File

@@ -0,0 +1,7 @@
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';
export { executeHistory } from './local/history.js';
export type { HistoryContext } from './local/history.js';

View 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();
}

View File

@@ -0,0 +1,53 @@
import type { ConversationMessage } from '../../gateway-api.js';
const CONTEXT_WINDOW = 200_000;
const CHARS_PER_TOKEN = 4;
function estimateTokens(messages: ConversationMessage[]): number {
const totalChars = messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
return Math.round(totalChars / CHARS_PER_TOKEN);
}
export interface HistoryContext {
conversationId: string | undefined;
conversationTitle?: string | null;
gatewayUrl: string;
sessionCookie: string | undefined;
fetchMessages: (
gatewayUrl: string,
sessionCookie: string,
conversationId: string,
) => Promise<ConversationMessage[]>;
}
export async function executeHistory(ctx: HistoryContext): Promise<string> {
const { conversationId, conversationTitle, gatewayUrl, sessionCookie, fetchMessages } = ctx;
if (!conversationId) {
return 'No active conversation.';
}
if (!sessionCookie) {
return 'Not authenticated — cannot fetch conversation messages.';
}
const messages = await fetchMessages(gatewayUrl, sessionCookie, conversationId);
const userMessages = messages.filter((m) => m.role === 'user').length;
const assistantMessages = messages.filter((m) => m.role === 'assistant').length;
const totalMessages = messages.length;
const estimatedTokens = estimateTokens(messages);
const contextPercent = Math.round((estimatedTokens / CONTEXT_WINDOW) * 100);
const label = conversationTitle ?? conversationId;
const lines = [
`Conversation: ${label}`,
`Messages: ${totalMessages} (${userMessages} user, ${assistantMessages} assistant)`,
`Estimated tokens: ~${estimatedTokens.toLocaleString()}`,
`Context usage: ~${contextPercent}% of ${(CONTEXT_WINDOW / 1000).toFixed(0)}K`,
];
return lines.join('\n');
}

View 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');
}

View 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,
};
}

View File

@@ -0,0 +1,137 @@
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: 'history',
description: 'Show conversation message count and context usage',
aliases: ['hist'],
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',
},
{
name: 'attach',
description: 'Attach a file to the next message (@file syntax also works inline)',
aliases: [],
args: [
{
name: 'path',
type: 'string' as const,
optional: false,
description: 'File path to attach',
},
],
execution: 'local',
available: true,
scope: 'core',
},
{
name: 'new',
description: 'Start a new conversation',
aliases: ['n'],
args: undefined,
execution: 'local',
available: true,
scope: 'core',
},
];
const ALIASES: Record<string, string> = {
m: 'model',
t: 'thinking',
a: 'agent',
s: 'status',
h: 'help',
hist: 'history',
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 ?? [];
// Local commands take precedence; deduplicate gateway commands that share
// a name with a local command to avoid duplicate React keys and confusing
// autocomplete entries.
const localNames = new Set(LOCAL_COMMANDS.map((c) => c.name));
const dedupedGateway = gateway.filter((c) => !localNames.has(c.name));
return [...LOCAL_COMMANDS, ...dedupedGateway];
}
getLocalCommands(): CommandDef[] {
return LOCAL_COMMANDS;
}
}
export const commandRegistry = new CommandRegistry();

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { Box, Text } from 'ink';
import type { RoutingDecisionInfo } from '@mosaic/types';
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;
/** Routing decision info for transparency display (M4-008) */
routingDecision?: RoutingDecisionInfo | null;
}
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,
routingDecision,
}: 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>
{/* Line 4: routing transparency (M4-008) — only shown when a routing decision is available */}
{routingDecision && (
<Box>
<Text dimColor>
Routed: {routingDecision.model} ({routingDecision.reason})
</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { Box, Text } from 'ink';
import type { CommandDef, CommandArgDef } from '@mosaic/types';
interface CommandAutocompleteProps {
commands: CommandDef[];
selectedIndex: number;
inputValue: string; // the current input after '/'
}
export function CommandAutocomplete({
commands,
selectedIndex,
inputValue,
}: CommandAutocompleteProps) {
if (commands.length === 0) return null;
// Filter by inputValue prefix/fuzzy match
const query = inputValue.startsWith('/') ? inputValue.slice(1) : inputValue;
const filtered = filterCommands(commands, query);
if (filtered.length === 0) return null;
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
const selected = filtered[clampedIndex];
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
{filtered.slice(0, 8).map((cmd, i) => (
<Box key={`${cmd.execution}-${cmd.name}`}>
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
</Text>
{cmd.aliases.length > 0 && (
<Text color="gray"> ({cmd.aliases.map((a) => `/${a}`).join(', ')})</Text>
)}
<Text color="gray"> {cmd.description}</Text>
</Box>
))}
{selected && selected.args && selected.args.length > 0 && (
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
<Text color="yellow">
/{selected.name} {getArgHint(selected.args)}
</Text>
<Text color="gray"> {selected.description}</Text>
</Box>
)}
</Box>
);
}
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
if (!query) return commands;
const q = query.toLowerCase();
return commands.filter(
(c) =>
c.name.includes(q) ||
c.aliases.some((a) => a.includes(q)) ||
c.description.toLowerCase().includes(q),
);
}
function getArgHint(args: CommandArgDef[]): string {
if (!args || args.length === 0) return '';
return args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ');
}

View File

@@ -0,0 +1,225 @@
import React, { useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import type { ParsedCommand, CommandDef } from '@mosaic/types';
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
import { CommandAutocomplete } from './command-autocomplete.js';
import { useInputHistory } from '../hooks/use-input-history.js';
import { useState } from 'react';
export interface InputBarProps {
/** Controlled input value — caller owns the state */
value: string;
onChange: (val: string) => void;
onSubmit: (value: string) => void;
onSystemMessage?: (message: string) => void;
onLocalCommand?: (parsed: ParsedCommand) => void;
onGatewayCommand?: (parsed: ParsedCommand) => void;
isStreaming: boolean;
connected: boolean;
/** Whether this input bar is focused/active (default true). When false,
* keyboard input is not captured — e.g. when the sidebar has focus. */
focused?: boolean;
placeholder?: string;
allCommands?: CommandDef[];
}
export function InputBar({
value: input,
onChange: setInput,
onSubmit,
onSystemMessage,
onLocalCommand,
onGatewayCommand,
isStreaming,
connected,
focused = true,
placeholder: placeholderOverride,
allCommands,
}: InputBarProps) {
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
const { addToHistory, navigateUp, navigateDown } = useInputHistory();
// Determine which commands to show in autocomplete
const availableCommands = allCommands ?? commandRegistry.getAll();
const handleChange = useCallback(
(value: string) => {
setInput(value);
if (value.startsWith('/')) {
setShowAutocomplete(true);
setAutocompleteIndex(0);
} else {
setShowAutocomplete(false);
}
},
[setInput],
);
const handleSubmit = useCallback(
(value: string) => {
if (!value.trim() || isStreaming || !connected) return;
const trimmed = value.trim();
addToHistory(trimmed);
setShowAutocomplete(false);
setAutocompleteIndex(0);
if (trimmed.startsWith('/')) {
const parsed = parseSlashCommand(trimmed);
if (!parsed) {
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
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,
addToHistory,
setInput,
],
);
// Handle Tab: fill in selected autocomplete command
const fillAutocompleteSelection = useCallback(() => {
if (!showAutocomplete) return false;
const query = input.startsWith('/') ? input.slice(1) : input;
const filtered = availableCommands.filter(
(c) =>
!query ||
c.name.includes(query.toLowerCase()) ||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
c.description.toLowerCase().includes(query.toLowerCase()),
);
if (filtered.length === 0) return false;
const idx = Math.min(autocompleteIndex, filtered.length - 1);
const selected = filtered[idx];
if (selected) {
setInput(`/${selected.name} `);
setShowAutocomplete(false);
setAutocompleteIndex(0);
return true;
}
return false;
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
useInput(
(_ch, key) => {
if (key.escape && showAutocomplete) {
setShowAutocomplete(false);
setAutocompleteIndex(0);
return;
}
// Tab: fill autocomplete selection
if (key.tab) {
fillAutocompleteSelection();
return;
}
// Up arrow
if (key.upArrow) {
if (showAutocomplete) {
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
} else {
const prev = navigateUp(input);
if (prev !== null) {
setInput(prev);
if (prev.startsWith('/')) setShowAutocomplete(true);
}
}
return;
}
// Down arrow
if (key.downArrow) {
if (showAutocomplete) {
const query = input.startsWith('/') ? input.slice(1) : input;
const filteredLen = availableCommands.filter(
(c) =>
!query ||
c.name.includes(query.toLowerCase()) ||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
c.description.toLowerCase().includes(query.toLowerCase()),
).length;
const maxVisible = Math.min(filteredLen, 8);
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
} else {
const next = navigateDown();
if (next !== null) {
setInput(next);
setShowAutocomplete(next.startsWith('/'));
}
}
return;
}
// Return/Enter on autocomplete: fill selected command
if (key.return && showAutocomplete) {
fillAutocompleteSelection();
return;
}
},
{ isActive: focused },
);
const placeholder =
placeholderOverride ??
(!connected
? 'disconnected — waiting for gateway…'
: isStreaming
? 'waiting for response…'
: 'message mosaic…');
return (
<Box flexDirection="column">
{showAutocomplete && (
<CommandAutocomplete
commands={availableCommands}
selectedIndex={autocompleteIndex}
inputValue={input}
/>
)}
<Box paddingX={1} borderStyle="single" borderColor="gray">
<Text bold color="green">
{' '}
</Text>
<TextInput
value={input}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder={placeholder}
focus={focused}
/>
</Box>
</Box>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,202 @@
/**
* File reference expansion for TUI chat input.
*
* Detects @path/to/file patterns in user messages, reads the file contents,
* and inlines them as fenced code blocks in the message.
*
* Supports:
* - @relative/path.ts
* - @./relative/path.ts
* - @/absolute/path.ts
* - @~/home-relative/path.ts
*
* Also provides an /attach <path> command handler.
*/
import { readFile, stat } from 'node:fs/promises';
import { resolve, extname, basename } from 'node:path';
import { homedir } from 'node:os';
const MAX_FILE_SIZE = 256 * 1024; // 256 KB
const MAX_FILES_PER_MESSAGE = 10;
/**
* Regex to detect @file references in user input.
* Matches @<path> where path starts with /, ./, ~/, or a word char,
* and continues until whitespace or end of string.
* Excludes @mentions that look like usernames (no dots/slashes).
*/
const FILE_REF_PATTERN = /(?:^|\s)@((?:\.{0,2}\/|~\/|[a-zA-Z0-9_])[^\s]+)/g;
interface FileRefResult {
/** The expanded message text with file contents inlined */
expandedMessage: string;
/** Files that were successfully read */
filesAttached: string[];
/** Errors encountered while reading files */
errors: string[];
}
function resolveFilePath(ref: string): string {
if (ref.startsWith('~/')) {
return resolve(homedir(), ref.slice(2));
}
return resolve(process.cwd(), ref);
}
function getLanguageHint(filePath: string): string {
const ext = extname(filePath).toLowerCase();
const map: Record<string, string> = {
'.ts': 'typescript',
'.tsx': 'typescript',
'.js': 'javascript',
'.jsx': 'javascript',
'.py': 'python',
'.rb': 'ruby',
'.rs': 'rust',
'.go': 'go',
'.java': 'java',
'.c': 'c',
'.cpp': 'cpp',
'.h': 'c',
'.hpp': 'cpp',
'.cs': 'csharp',
'.sh': 'bash',
'.bash': 'bash',
'.zsh': 'zsh',
'.fish': 'fish',
'.json': 'json',
'.yaml': 'yaml',
'.yml': 'yaml',
'.toml': 'toml',
'.xml': 'xml',
'.html': 'html',
'.css': 'css',
'.scss': 'scss',
'.md': 'markdown',
'.sql': 'sql',
'.graphql': 'graphql',
'.dockerfile': 'dockerfile',
'.tf': 'terraform',
'.vue': 'vue',
'.svelte': 'svelte',
};
return map[ext] ?? '';
}
/**
* Check if the input contains any @file references.
*/
export function hasFileRefs(input: string): boolean {
FILE_REF_PATTERN.lastIndex = 0;
return FILE_REF_PATTERN.test(input);
}
/**
* Expand @file references in a message by reading file contents
* and appending them as fenced code blocks.
*/
export async function expandFileRefs(input: string): Promise<FileRefResult> {
const refs: string[] = [];
FILE_REF_PATTERN.lastIndex = 0;
let match;
while ((match = FILE_REF_PATTERN.exec(input)) !== null) {
const ref = match[1]!;
if (!refs.includes(ref)) {
refs.push(ref);
}
}
if (refs.length === 0) {
return { expandedMessage: input, filesAttached: [], errors: [] };
}
if (refs.length > MAX_FILES_PER_MESSAGE) {
return {
expandedMessage: input,
filesAttached: [],
errors: [`Too many file references (${refs.length}). Maximum is ${MAX_FILES_PER_MESSAGE}.`],
};
}
const filesAttached: string[] = [];
const errors: string[] = [];
const attachments: string[] = [];
for (const ref of refs) {
const filePath = resolveFilePath(ref);
try {
const info = await stat(filePath);
if (!info.isFile()) {
errors.push(`@${ref}: not a file`);
continue;
}
if (info.size > MAX_FILE_SIZE) {
errors.push(
`@${ref}: file too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
);
continue;
}
const content = await readFile(filePath, 'utf8');
const lang = getLanguageHint(filePath);
const name = basename(filePath);
attachments.push(`\n📎 ${ref} (${name}):\n\`\`\`${lang}\n${content}\n\`\`\``);
filesAttached.push(ref);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Only report meaningful errors — ENOENT is common for false @mention matches
if (msg.includes('ENOENT')) {
// Check if this looks like a file path (has extension or slash)
if (ref.includes('/') || ref.includes('.')) {
errors.push(`@${ref}: file not found`);
}
// Otherwise silently skip — likely an @mention, not a file ref
} else {
errors.push(`@${ref}: ${msg}`);
}
}
}
if (attachments.length === 0) {
return { expandedMessage: input, filesAttached, errors };
}
const expandedMessage = input + '\n' + attachments.join('\n');
return { expandedMessage, filesAttached, errors };
}
/**
* Handle the /attach <path> command.
* Reads a file and returns the content formatted for inclusion in the chat.
*/
export async function handleAttachCommand(
args: string,
): Promise<{ content: string; error?: string }> {
const filePath = args.trim();
if (!filePath) {
return { content: '', error: 'Usage: /attach <file-path>' };
}
const resolved = resolveFilePath(filePath);
try {
const info = await stat(resolved);
if (!info.isFile()) {
return { content: '', error: `Not a file: ${filePath}` };
}
if (info.size > MAX_FILE_SIZE) {
return {
content: '',
error: `File too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
};
}
const content = await readFile(resolved, 'utf8');
const lang = getLanguageHint(resolved);
const name = basename(resolved);
return {
content: `📎 Attached file: ${name} (${filePath})\n\`\`\`${lang}\n${content}\n\`\`\``,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { content: '', error: `Failed to read file: ${msg}` };
}
}

View File

@@ -0,0 +1,438 @@
/**
* Minimal gateway REST API client for the TUI and CLI commands.
*/
export interface ModelInfo {
id: string;
provider: string;
name: string;
}
export interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: ModelInfo[];
}
export interface SessionInfo {
id: string;
provider: string;
modelId: string;
createdAt: string;
promptCount: number;
channels: string[];
durationMs: number;
}
export interface SessionListResult {
sessions: SessionInfo[];
total: number;
}
// ── Agent Config types ──
export interface AgentConfigInfo {
id: string;
name: string;
provider: string;
model: string;
status: string;
projectId: string | null;
ownerId: string | null;
systemPrompt: string | null;
allowedTools: string[] | null;
skills: string[] | null;
isSystem: boolean;
config: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
// ── Project types ──
export interface ProjectInfo {
id: string;
name: string;
description: string | null;
status: string;
ownerId: string | null;
createdAt: string;
updatedAt: string;
}
// ── Mission types ──
export interface MissionInfo {
id: string;
name: string;
description: string | null;
status: string;
projectId: string | null;
userId: string | null;
phase: string | null;
milestones: Record<string, unknown>[] | null;
config: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
// ── Mission Task types ──
export interface MissionTaskInfo {
id: string;
missionId: string;
taskId: string | null;
userId: string;
status: string;
description: string | null;
notes: string | null;
pr: string | null;
createdAt: string;
updatedAt: string;
}
// ── Helpers ──
function headers(sessionCookie: string, gatewayUrl: string) {
return { Cookie: sessionCookie, Origin: gatewayUrl };
}
function jsonHeaders(sessionCookie: string, gatewayUrl: string) {
return { ...headers(sessionCookie, gatewayUrl), 'Content-Type': 'application/json' };
}
async function handleResponse<T>(res: Response, errorPrefix: string): Promise<T> {
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`${errorPrefix} (${res.status}): ${body}`);
}
return (await res.json()) as T;
}
// ── Conversation types ──
export interface ConversationInfo {
id: string;
title: string | null;
archived: boolean;
createdAt: string;
updatedAt: string;
}
// ── Conversation endpoints ──
export async function createConversation(
gatewayUrl: string,
sessionCookie: string,
data: { title?: string; projectId?: string } = {},
): Promise<ConversationInfo> {
const res = await fetch(`${gatewayUrl}/api/conversations`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<ConversationInfo>(res, 'Failed to create conversation');
}
// ── Provider / Model endpoints ──
export async function fetchAvailableModels(
gatewayUrl: string,
sessionCookie?: string,
): Promise<ModelInfo[]> {
try {
const res = await fetch(`${gatewayUrl}/api/providers/models`, {
headers: {
...(sessionCookie ? { Cookie: sessionCookie } : {}),
Origin: gatewayUrl,
},
});
if (!res.ok) return [];
const data = (await res.json()) as ModelInfo[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export async function fetchProviders(
gatewayUrl: string,
sessionCookie?: string,
): Promise<ProviderInfo[]> {
try {
const res = await fetch(`${gatewayUrl}/api/providers`, {
headers: {
...(sessionCookie ? { Cookie: sessionCookie } : {}),
Origin: gatewayUrl,
},
});
if (!res.ok) return [];
const data = (await res.json()) as ProviderInfo[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
// ── Session endpoints ──
export async function fetchSessions(
gatewayUrl: string,
sessionCookie: string,
): Promise<SessionListResult> {
const res = await fetch(`${gatewayUrl}/api/sessions`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
}
export async function deleteSession(
gatewayUrl: string,
sessionCookie: string,
sessionId: string,
): Promise<void> {
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
headers: headers(sessionCookie, gatewayUrl),
});
if (!res.ok && res.status !== 204) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
}
}
// ── Agent Config endpoints ──
export async function fetchAgentConfigs(
gatewayUrl: string,
sessionCookie: string,
): Promise<AgentConfigInfo[]> {
const res = await fetch(`${gatewayUrl}/api/agents`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<AgentConfigInfo[]>(res, 'Failed to list agents');
}
export async function fetchAgentConfig(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<AgentConfigInfo> {
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<AgentConfigInfo>(res, 'Failed to get agent');
}
export async function createAgentConfig(
gatewayUrl: string,
sessionCookie: string,
data: {
name: string;
provider: string;
model: string;
projectId?: string;
systemPrompt?: string;
allowedTools?: string[];
skills?: string[];
config?: Record<string, unknown>;
},
): Promise<AgentConfigInfo> {
const res = await fetch(`${gatewayUrl}/api/agents`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<AgentConfigInfo>(res, 'Failed to create agent');
}
export async function updateAgentConfig(
gatewayUrl: string,
sessionCookie: string,
id: string,
data: Record<string, unknown>,
): Promise<AgentConfigInfo> {
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<AgentConfigInfo>(res, 'Failed to update agent');
}
export async function deleteAgentConfig(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<void> {
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: headers(sessionCookie, gatewayUrl),
});
if (!res.ok && res.status !== 204) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to delete agent (${res.status}): ${body}`);
}
}
// ── Project endpoints ──
export async function fetchProjects(
gatewayUrl: string,
sessionCookie: string,
): Promise<ProjectInfo[]> {
const res = await fetch(`${gatewayUrl}/api/projects`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<ProjectInfo[]>(res, 'Failed to list projects');
}
// ── Mission endpoints ──
export async function fetchMissions(
gatewayUrl: string,
sessionCookie: string,
): Promise<MissionInfo[]> {
const res = await fetch(`${gatewayUrl}/api/missions`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<MissionInfo[]>(res, 'Failed to list missions');
}
export async function fetchMission(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<MissionInfo> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<MissionInfo>(res, 'Failed to get mission');
}
export async function createMission(
gatewayUrl: string,
sessionCookie: string,
data: {
name: string;
description?: string;
projectId?: string;
status?: string;
phase?: string;
milestones?: Record<string, unknown>[];
config?: Record<string, unknown>;
},
): Promise<MissionInfo> {
const res = await fetch(`${gatewayUrl}/api/missions`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<MissionInfo>(res, 'Failed to create mission');
}
export async function updateMission(
gatewayUrl: string,
sessionCookie: string,
id: string,
data: Record<string, unknown>,
): Promise<MissionInfo> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<MissionInfo>(res, 'Failed to update mission');
}
export async function deleteMission(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<void> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: headers(sessionCookie, gatewayUrl),
});
if (!res.ok && res.status !== 204) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to delete mission (${res.status}): ${body}`);
}
}
// ── Conversation Message types ──
export interface ConversationMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
createdAt: string;
}
// ── Conversation Message endpoints ──
export async function fetchConversationMessages(
gatewayUrl: string,
sessionCookie: string,
conversationId: string,
): Promise<ConversationMessage[]> {
const res = await fetch(
`${gatewayUrl}/api/conversations/${encodeURIComponent(conversationId)}/messages`,
{
headers: headers(sessionCookie, gatewayUrl),
},
);
return handleResponse<ConversationMessage[]>(res, 'Failed to fetch conversation messages');
}
// ── Mission Task endpoints ──
export async function fetchMissionTasks(
gatewayUrl: string,
sessionCookie: string,
missionId: string,
): Promise<MissionTaskInfo[]> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<MissionTaskInfo[]>(res, 'Failed to list mission tasks');
}
export async function createMissionTask(
gatewayUrl: string,
sessionCookie: string,
missionId: string,
data: {
description?: string;
status?: string;
notes?: string;
pr?: string;
taskId?: string;
},
): Promise<MissionTaskInfo> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<MissionTaskInfo>(res, 'Failed to create mission task');
}
export async function updateMissionTask(
gatewayUrl: string,
sessionCookie: string,
missionId: string,
taskId: string,
data: Record<string, unknown>,
): Promise<MissionTaskInfo> {
const res = await fetch(
`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks/${encodeURIComponent(taskId)}`,
{
method: 'PATCH',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
},
);
return handleResponse<MissionTaskInfo>(res, 'Failed to update mission task');
}

View File

@@ -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 };
}

View File

@@ -0,0 +1,143 @@
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(
(includeContentType = true): Record<string, string> => {
const h: Record<string, string> = { Origin: gatewayUrl };
if (includeContentType) h['Content-Type'] = 'application/json';
if (sessionCookie) h['Cookie'] = sessionCookie;
return h;
},
[gatewayUrl, sessionCookie],
);
const refresh = useCallback(async () => {
if (!mountedRef.current) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers(false) });
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(false),
});
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,
};
}

View 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;
}

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from 'vitest';
/**
* Tests for input history logic extracted from useInputHistory.
* We test the pure state transitions directly rather than using
* React testing utilities to avoid react-dom version conflicts.
*/
const MAX_HISTORY = 50;
function createHistoryState() {
let history: string[] = [];
let historyIndex = -1;
let savedInput = '';
function addToHistory(input: string): void {
if (!input.trim()) return;
if (history[0] === input) return;
history = [input, ...history].slice(0, MAX_HISTORY);
historyIndex = -1;
}
function navigateUp(currentInput: string): string | null {
if (history.length === 0) return null;
if (historyIndex === -1) {
savedInput = currentInput;
}
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
historyIndex = nextIndex;
return history[nextIndex] ?? null;
}
function navigateDown(): string | null {
if (historyIndex <= 0) {
historyIndex = -1;
return savedInput;
}
const nextIndex = historyIndex - 1;
historyIndex = nextIndex;
return history[nextIndex] ?? null;
}
function resetNavigation(): void {
historyIndex = -1;
}
function getHistoryLength(): number {
return history.length;
}
return { addToHistory, navigateUp, navigateDown, resetNavigation, getHistoryLength };
}
describe('useInputHistory (logic)', () => {
let h: ReturnType<typeof createHistoryState>;
beforeEach(() => {
h = createHistoryState();
});
it('adds to history on submit', () => {
h.addToHistory('hello');
h.addToHistory('world');
// navigateUp should return 'world' first (most recent)
const val = h.navigateUp('');
expect(val).toBe('world');
});
it('does not add empty strings to history', () => {
h.addToHistory('');
h.addToHistory(' ');
const val = h.navigateUp('');
expect(val).toBeNull();
});
it('navigateDown after up returns saved input', () => {
h.addToHistory('first');
const up = h.navigateUp('current');
expect(up).toBe('first');
const down = h.navigateDown();
expect(down).toBe('current');
});
it('does not add duplicate consecutive entries', () => {
h.addToHistory('same');
h.addToHistory('same');
expect(h.getHistoryLength()).toBe(1);
});
it('caps history at MAX_HISTORY entries', () => {
for (let i = 0; i < 55; i++) {
h.addToHistory(`entry-${i}`);
}
expect(h.getHistoryLength()).toBe(50);
// Navigate to the oldest entry
let val: string | null = null;
for (let i = 0; i < 60; i++) {
val = h.navigateUp('');
}
// Oldest entry at index 49 = entry-5 (entries 54 down to 5, 50 total)
expect(val).toBe('entry-5');
});
it('navigateUp returns null when history is empty', () => {
const val = h.navigateUp('something');
expect(val).toBeNull();
});
it('navigateUp cycles through multiple entries', () => {
h.addToHistory('a');
h.addToHistory('b');
h.addToHistory('c');
expect(h.navigateUp('')).toBe('c');
expect(h.navigateUp('c')).toBe('b');
expect(h.navigateUp('b')).toBe('a');
});
it('resetNavigation resets index to -1', () => {
h.addToHistory('test');
h.navigateUp('');
h.resetNavigation();
// After reset, navigateUp from index -1 returns most recent again
const val = h.navigateUp('');
expect(val).toBe('test');
});
});

View File

@@ -0,0 +1,48 @@
import { useState, useCallback } from 'react';
const MAX_HISTORY = 50;
export function useInputHistory() {
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const [savedInput, setSavedInput] = useState<string>('');
const addToHistory = useCallback((input: string) => {
if (!input.trim()) return;
setHistory((prev) => {
// Avoid duplicate consecutive entries
if (prev[0] === input) return prev;
return [input, ...prev].slice(0, MAX_HISTORY);
});
setHistoryIndex(-1);
}, []);
const navigateUp = useCallback(
(currentInput: string): string | null => {
if (history.length === 0) return null;
if (historyIndex === -1) {
setSavedInput(currentInput);
}
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
setHistoryIndex(nextIndex);
return history[nextIndex] ?? null;
},
[history, historyIndex],
);
const navigateDown = useCallback((): string | null => {
if (historyIndex <= 0) {
setHistoryIndex(-1);
return savedInput;
}
const nextIndex = historyIndex - 1;
setHistoryIndex(nextIndex);
return history[nextIndex] ?? null;
}, [history, historyIndex, savedInput]);
const resetNavigation = useCallback(() => {
setHistoryIndex(-1);
}, []);
return { addToHistory, navigateUp, navigateDown, resetNavigation };
}

View 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,
};
}

View File

@@ -0,0 +1,339 @@
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,
SlashCommandResultPayload,
SystemReloadPayload,
RoutingDecisionInfo,
} 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[];
/** Last routing decision received from the gateway (M4-008) */
routingDecision: RoutingDecisionInfo | null;
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 [routingDecision, setRoutingDecision] = useState<RoutingDecisionInfo | null>(null);
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);
// Update routing decision if provided (M4-008)
if (data.routingDecision) {
setRoutingDecision(data.routingDecision);
}
});
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);
});
socket.on('command:result', (data: SlashCommandResultPayload) => {
const prefix = data.success ? '' : 'Error: ';
const text = data.message ?? (data.success ? 'Done.' : 'Command failed.');
setMessages((msgs) => [
...msgs,
{ role: 'system', content: `${prefix}${text}`, timestamp: new Date() },
]);
});
socket.on('system:reload', (data: SystemReloadPayload) => {
commandRegistry.updateManifest({
commands: data.commands,
skills: data.skills,
version: Date.now(),
});
setMessages((msgs) => [
...msgs,
{ role: 'system', content: data.message, timestamp: new Date() },
]);
});
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,
routingDecision,
sendMessage,
addSystemMessage,
setThinkingLevel,
switchConversation,
clearMessages,
connectionError,
socketRef,
};
}

View 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,
};
}

Some files were not shown because too many files have changed in this diff Show More