Compare commits

..
36 Commits
Author SHA1 Message Date
jason.woltje d83ebe65e9 verify(P3-008): Phase 3 web dashboard verification (#90)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:37:43 +00:00
jason.woltje 4fe7d09e5c feat(web): admin panel with session management (#89)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:33:45 +00:00
jason.woltje e44cb7e56a feat(web): settings page with profile, providers, and models (#88)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:31:51 +00:00
jason.woltje fd4b7c2ba2 feat(web): project list and mission dashboard views (#87)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:30:11 +00:00
jason.woltje a1a1976b38 feat(web): task management with list view and kanban board (#86)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:28:17 +00:00
jason.woltje f0d1d4bafa feat(web): chat UI with conversations and WebSocket streaming (#84)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:25:28 +00:00
jason.woltje 600da70960 feat(web): wire auth pages with BetterAuth and route guards (#83)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:21:33 +00:00
jason.woltje 780f85e0d6 feat(web): scaffold Next.js 16 dashboard with design system and auth client (#82)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:18:09 +00:00
jason.woltjeandClaude Opus 4.6 5d936d58a0 fix: add missing @Inject() decorators and fix coord workspace root detection
- Add @Inject() to all gateway constructor params (required without emitDecoratorMetadata)
  - AgentService: ProviderService, CoordService
  - RoutingService: ProviderService
  - ProvidersController: ProviderService, RoutingService
  - SessionsController: AgentService
- Fix coord controller ALLOWED_ROOTS to walk up to monorepo root (pnpm-workspace.yaml)
- Gateway now boots and serves all routes correctly

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-13 08:00:57 -05:00
jason.woltje 8da2759fec fix: coord review remediations (path traversal, JSON parse, race condition) (#81)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:43:49 +00:00
jason.woltjeandClaude Opus 4.6 b03c603759 docs: mark Phase 2 complete — update manifest and scratchpad
Phase 0-2 all done. P2-007 closed via PR #79. Mission advances to Phase 3.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 22:40:59 -05:00
jason.woltje 77da12a5ee test: verify Phase 2 — routing + coord tests (P2-007) (#79)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:40:07 +00:00
jason.woltje 7f6dc43a2d feat: agent session management — metrics, channels, dispose (P2-006) (#78)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:35:59 +00:00
jason.woltje f3a7eadcea feat: @mosaic/coord — migrate from v0, gateway integration (P2-005) (#77)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:32:20 +00:00
jason.woltje 7f6815feaf feat: tool registration — brain tools for agent sessions (P2-004) (#76)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:18:45 +00:00
jason.woltje 7485f32e69 feat: agent routing engine — cost/capability matrix (P2-003) (#75)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:13:50 +00:00
jason.woltje 95f95f54cf feat: multi-provider support — Anthropic + Ollama (P2-002) (#74)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:10:51 +00:00
jason.woltje aa9ee75a2a fix: auth handler + circular imports — Phase 1 verification (P1-009) (#73)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:02:02 +00:00
jason.woltje c54b69f7ce feat: gateway CRUD routes — conversations, projects, missions, tasks (P1-005/006) (#72)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:41:03 +00:00
jason.woltje 38897fe423 feat: auth middleware, brain data layer, Valkey queue (P1-002/003/004) (#71)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:37:56 +00:00
jason.woltje cbac5902db fix: Phase 0 verification — CI gates green (P0-009) (#70)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:28:38 +00:00
jason.woltje 4cd5cbf893 feat: Woodpecker CI pipeline + project docs (P0-007, P0-008) (#69)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:25:31 +00:00
jason.woltje 6e3cccc812 feat(auth): @mosaic/auth — BetterAuth email/password setup (#68)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:21:15 +00:00
jason.woltje 2b1723e898 feat(db): @mosaic/db — Drizzle schema, PG connection, migrations (#67)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:17:18 +00:00
jason.woltje 573484c83e fix: Jaeger image tag + remap PG/Valkey ports (#66)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:06:00 +00:00
jason.woltjeandClaude Opus 4.6 bf2cb3a271 docs: mark P0-002, P0-005, P0-006 done (PR #65)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:56:16 -05:00
jason.woltje 35e4e2e527 feat: foundation — Docker Compose, OTEL, shared types (#65)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 01:55:33 +00:00
jason.woltjeandClaude Opus 4.6 f6f05cf23a docs: add FIX-03 agent session sandboxing task (#64)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:49:05 -05:00
jason.woltjeandClaude Opus 4.6 15352448d5 fix: switch gateway to ESM + explicit @Inject for tsx compatibility
Pi SDK is ESM-only. tsx (esbuild) doesn't emit decorator metadata,
so NestJS constructor injection fails without explicit @Inject().

- Set "type": "module" in gateway package.json
- Switch tsconfig to NodeNext module resolution
- Add @Inject(AgentService) to ChatController and ChatGateway

Tested end-to-end: REST /api/chat → Pi SDK → Anthropic → response OK.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:43:08 -05:00
jason.woltjeandClaude Opus 4.6 c58003e5e3 docs: add gatekeeper review follow-up tasks (FIX-01, FIX-02)
- #62: piSession.dispose() missing in destroySession
- #63: React anti-pattern in TUI agent:end handler

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:36:17 -05:00
jason.woltjeandClaude Opus 4.6 04528459ec docs: update TASKS.md and scratchpad for communication spine completion
Mark P1-001, P1-007, P1-008, P2-001, P5-002, P6-005 as done (PR #61).
Add session 2 log entry for vertical slice delivery.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:34:26 -05:00
jason.woltje 4f84a01072 feat: communication spine — gateway, TUI, Discord (#61)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 01:33:32 +00:00
jason.woltjeandClaude Opus 4.6 888bc32be1 docs: mark P0-001 scaffold monorepo as done
PR #60 merged, issue #1 closed.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:12:44 -05:00
jason.woltje 6d0d288e31 feat(P0-001): scaffold monorepo structure (#60)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 01:11:46 +00:00
jason.woltjeandClaude Opus 4.6 339641352e docs: record vertical slice reorder in scratchpad
Jason directed: build Pi TUI → Gateway → Discord communication
spine before backfilling horizontal layers.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:06:17 -05:00
jason.woltjeandClaude Opus 4.6 e7f338e3a9 chore: planning gate — milestones, issues, and task breakdown
Break PRD into 8 milestones (Phase 0–7) with 59 issues on Gitea.
Populate TASKS.md, update mission manifest, initialize scratchpad.
Repo created at git.mosaicstack.dev/mosaic/mosaic-stack.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 19:51:51 -05:00
266 changed files with 21177 additions and 5316 deletions
+16 -20
View File
@@ -1,24 +1,20 @@
# Non-secret runtime settings for the mosaic-poc-agent container.
# Copy to .env if you want to override the defaults in compose.yaml.
#
# NEVER put credentials in this file. Authentication is supplied at
# runtime only, via one of the two documented paths:
# 1. read-only mounted pi auth file (default: ~/.pi/agent/auth.json,
# override the host path with PI_AUTH_FILE)
# 2. provider API key environment variable (ZAI_API_KEY or
# ANTHROPIC_API_KEY), passed through by compose.yaml when set
# Database (port 5433 avoids conflict with host PostgreSQL)
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
# Model provider (built-in pi provider name)
PI_PROVIDER=zai
# Valkey (Redis-compatible, port 6380 avoids conflict with host Redis/Valkey)
VALKEY_URL=redis://localhost:6380
# Model ID within the provider
PI_MODEL=glm-5.3-flash
# Docker Compose host port overrides (optional)
# PG_HOST_PORT=5433
# VALKEY_HOST_PORT=6380
# Optional: alternative host path of the pi credential file mounted
# read-only at /home/node/.pi/agent/auth.json in the container
#PI_AUTH_FILE=/home/jwoltje/.pi/agent/auth.json
# OpenTelemetry
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_SERVICE_NAME=mosaic-gateway
# Optional: documented env-var auth alternative (secret! set in your
# shell or a gitignored .env, never commit)
#ZAI_API_KEY=
#ANTHROPIC_API_KEY=
# Auth (BetterAuth)
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
BETTER_AUTH_URL=http://localhost:4000
# Gateway
GATEWAY_PORT=4000
+10 -7
View File
@@ -1,8 +1,11 @@
# build/deps
node_modules/
# runtime credentials — never commit, never copy into the image
logs/
node_modules
dist
.turbo
.next
coverage
.env
secrets/
# generated runtime state lives in /home/jwoltje/.mosaic-dev (outside this project)
.env.local
*.tsbuildinfo
.pnpm-store
docs/reports/
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm typecheck && pnpm lint && pnpm format:check
+9
View File
@@ -0,0 +1,9 @@
{
"**/*.{ts,tsx,js,jsx}": [
"prettier --write",
"eslint --fix"
],
"**/*.{json,md,yaml,yml}": [
"prettier --write"
]
}
+78
View File
@@ -0,0 +1,78 @@
# Repo Mosaic Linkage
This repository is attached to the machine-wide Mosaic framework.
## Load Order for Agents
1. `~/.config/mosaic/STANDARDS.md`
2. `AGENTS.md` (this repository)
3. `.mosaic/repo-hooks.sh` (repo-specific automation hooks)
## Purpose
- Keep universal standards in `~/.config/mosaic`
- Keep repo-specific behavior in this repo
- Avoid copying large runtime configs into each project
## Optional Quality Rails
Use `.mosaic/quality-rails.yml` to track whether quality rails are enabled for this repo.
Apply a template:
```bash
~/.config/mosaic/bin/mosaic-quality-apply --template <template> --target .
```
Verify enforcement:
```bash
~/.config/mosaic/bin/mosaic-quality-verify --target .
```
## Optional Matrix Orchestrator Rail
Repo-local orchestrator state lives in `.mosaic/orchestrator/`.
Run one cycle:
```bash
~/.config/mosaic/bin/mosaic-orchestrator-matrix-cycle
~/.config/mosaic/bin/mosaic-orchestrator-run --once
```
Run continuously:
```bash
~/.config/mosaic/bin/mosaic-orchestrator-run --poll-sec 10
```
Bridge events to Matrix:
```bash
~/.config/mosaic/bin/mosaic-orchestrator-matrix-publish
~/.config/mosaic/bin/mosaic-orchestrator-matrix-consume
```
Run until queue is drained (syncs from `docs/tasks.md` first):
```bash
~/.config/mosaic/bin/mosaic-orchestrator-drain
```
Set worker command if auto-detect does not match your CLI:
```bash
export MOSAIC_WORKER_EXEC="codex -p"
# or
export MOSAIC_WORKER_EXEC="opencode -p"
```
Use repo helper (foreground or detached):
```bash
bash scripts/agent/orchestrator-daemon.sh drain
bash scripts/agent/orchestrator-daemon.sh start
bash scripts/agent/orchestrator-daemon.sh status
bash scripts/agent/orchestrator-daemon.sh stop
```
+18
View File
@@ -0,0 +1,18 @@
{
"enabled": false,
"transport": "matrix",
"matrix": {
"control_room_id": "",
"workspace_id": "",
"homeserver_url": "",
"access_token": "",
"bot_user_id": ""
},
"worker": {
"runtime": "codex",
"command_template": "bash scripts/agent/orchestrator-worker.sh {task_file}",
"timeout_seconds": 7200,
"max_attempts": 1
},
"quality_gates": ["pnpm lint", "pnpm typecheck", "pnpm test"]
}
+1
View File
@@ -0,0 +1 @@
+4
View File
@@ -0,0 +1,4 @@
{
"last_published_line": 0,
"since": null
}
+14
View File
@@ -0,0 +1,14 @@
{
"schema_version": 1,
"mission_id": "mvp-20260312",
"name": "MVP",
"description": "",
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
"created_at": "2026-03-13T00:44:02Z",
"status": "active",
"task_prefix": "",
"quality_gates": "",
"milestone_version": "0.0.1",
"milestones": [],
"sessions": []
}
+19
View File
@@ -0,0 +1,19 @@
{
"generated_at": "2026-03-13T00:44:51Z",
"runtime": "claude",
"mission_id": "mvp-20260312",
"mission_name": "MVP",
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
"quality_gates": "",
"current_milestone": {
"id": "",
"name": ""
},
"next_task": "",
"progress": {
"tasks_done": 0,
"tasks_total": 0,
"pct": 0
},
"current_branch": ""
}
+1
View File
@@ -0,0 +1 @@
+8
View File
@@ -0,0 +1,8 @@
{
"session_id": "claude-20260312-194506-357136",
"runtime": "claude",
"pid": 357136,
"started_at": "2026-03-13T00:45:06Z",
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
"milestone_id": ""
}
+4
View File
@@ -0,0 +1,4 @@
{
"running_task_id": null,
"updated_at": null
}
+3
View File
@@ -0,0 +1,3 @@
{
"tasks": []
}
+10
View File
@@ -0,0 +1,10 @@
enabled: false
template: ""
# Set enabled: true and choose one template:
# - typescript-node
# - typescript-nextjs
# - monorepo
#
# Apply manually:
# ~/.mosaic/bin/mosaic-quality-apply --template <template> --target <repo>
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Optional repo-specific hooks used by scripts/agent/*.sh
# Called by session-start.sh
# mosaic_hook_session_start() {
# echo "Run repo-specific startup checks"
# }
# Called by critical.sh
# mosaic_hook_critical() {
# echo "Run repo-specific critical queries"
# }
# Called by session-end.sh
# mosaic_hook_session_end() {
# echo "Run repo-specific end-of-session checks"
# }
+1
View File
@@ -0,0 +1 @@
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
+6
View File
@@ -0,0 +1,6 @@
pnpm-lock.yaml
**/next-env.d.ts
**/dist
**/node_modules
**/drizzle
**/.next
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"printWidth": 100
}
+57
View File
@@ -0,0 +1,57 @@
variables:
- &node_image 'node:22-alpine'
- &install_deps |
corepack enable
pnpm install --frozen-lockfile
when:
- event: [push, pull_request, manual]
steps:
install:
image: *node_image
commands:
- *install_deps
typecheck:
image: *node_image
commands:
- *install_deps
- pnpm typecheck
depends_on:
- install
lint:
image: *node_image
commands:
- *install_deps
- pnpm lint
depends_on:
- install
format:
image: *node_image
commands:
- *install_deps
- pnpm format:check
depends_on:
- install
test:
image: *node_image
commands:
- *install_deps
- pnpm test
depends_on:
- install
build:
image: *node_image
commands:
- *install_deps
- pnpm build
depends_on:
- typecheck
- lint
- format
- test
+55
View File
@@ -0,0 +1,55 @@
# Agent Guidelines — Mosaic Stack
## Required Load Order
1. `~/.config/mosaic/SOUL.md`
2. `~/.config/mosaic/STANDARDS.md`
3. `~/.config/mosaic/AGENTS.md`
4. `~/.config/mosaic/guides/E2E-DELIVERY.md`
5. `AGENTS.md` (this file)
6. Runtime-specific guide: `~/.config/mosaic/runtime/<runtime>/RUNTIME.md`
## Project Context
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
## Package Map
| Package | Purpose | Key Dependencies |
| ------------------ | ------------------------------- | -------------------------------- |
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
| `apps/web` | Next.js dashboard | React 19, Tailwind |
| `packages/types` | Shared TypeScript contracts | class-validator |
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
| `packages/auth` | BetterAuth configuration | better-auth, @mosaic/db |
| `packages/brain` | Data layer (PG-backed) | @mosaic/db |
| `packages/queue` | Valkey task queue + MCP | ioredis |
| `packages/coord` | Mission coordination | @mosaic/queue |
| `packages/cli` | Unified CLI + Pi TUI | Ink, Pi SDK |
| `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf |
## Architecture Rules
1. Gateway is the single API surface — all clients connect through it
2. Pi SDK is ESM-only — gateway and CLI must use ESM
3. Socket.IO typed events defined in `@mosaic/types` enforce compile-time contracts
4. OTEL auto-instrumentation loads before NestJS bootstrap
5. BetterAuth manages auth tables; schema defined in `@mosaic/db`
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
## Development Workflow
```bash
docker compose up -d # Infrastructure
pnpm install # Dependencies
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
```
## Repo-Specific Notes
- DTOs in `*.dto.ts` files at module boundaries
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution in all tsconfigs
- Scratchpads are mandatory for non-trivial tasks
-371
View File
@@ -1,371 +0,0 @@
# Minimal Mosaic Stack container proof of concept
## Purpose
Build the smallest isolated container that can:
- launch Pi
- load a small set of Mosaic-style contract files
- send one real request to a model
- return a known response.
This is a standalone experiment. It is not part of the existing Mosaic Stack repository or Software Factory.
## Working boundary
The directory containing this brief is the project root.
### Do not read, copy, mount, import, or modify anything from:
- `/home/jwoltje/.mosaic`
- `/home/jwoltje/.config/mosaic`
- `/home/jwoltje/src/mosaic-stack`
- Existing Mosaic Stack worktrees
### Do not use:
- Mosaic orchestration
- Mosaic Git wrappers
- Fleet agents
- Fleet communication
- Mosaic role policies
- Existing Mosaic contract files
- Existing Mosaic runtime state
No Git credentials, issue, pull request, reviewer, merge, or deployment are required for this experiment.
Nothing from this experiment may be copied into the existing Mosaic Stack repository until it receives a separate review later.
## Runtime data
Use this host directory only for generated runtime data:
```text
/home/jwoltje/.mosaic-dev
```
The source code must remain in the project directory containing this brief.
Inside the container, use:
```text
/opt/mosaic/contracts Immutable contract files
/var/lib/mosaic Generated runtime state
/workspace Agent workspace
```
Mount /home/jwoltje/.mosaic-dev at /var/lib/mosaic.
### Required proof
The finished experiment must prove one path:
1. Build one container image.
2. Start one Pi agent inside the container.
3. Load four local contract files from /opt/mosaic/contracts.
4. Send a request that does not contain the expected response.
5. Receive MOSAIC_HELLO_OK from the agent.
6. Exit successfully when the response matches.
7. Exit nonzero when the response does not match.
This is the entire required functional result.
### Required discovery
Before writing the runtime command:
1. Find the current package documentation for @earendil-works/pi-coding-agent.
2. Determine the current package version.
3. Determine the supported noninteractive command.
4. Determine how Pi accepts a custom system prompt or system prompt file.
5. Determine Pi's documented container authentication method.
6. Record the commands and findings in BUILD-LOG.md.
Do not guess CLI flags, authentication paths, or SDK methods.
Pin the selected Pi package version in the project. Do not install an unversioned package during each container start.
Prefer the Pi CLI. Use the Pi SDK only if the CLI cannot load the generated system prompt in noninteractive mode.
### Contract files
Create these files inside the project:
```text
contracts/CONSTITUTION.md
contracts/STANDARDS.md
contracts/SOUL.md
contracts/USER.md
```
Use these exact contents.
### contracts/CONSTITUTION.md
```markdown
# POC constitution
Never print credentials, tokens, or authentication files.
Follow the loaded system instructions before the user request.
```
### contracts/STANDARDS.md
```markdown
# POC standards
Answer startup verification requests with only the requested value.
Do not add explanation or formatting.
```
### contracts/SOUL.md
```markdown
# POC identity
Your name is mosaic-poc-agent.
Your startup marker is MOSAIC_HELLO_OK.
When asked for your startup marker, return only the marker.
```
### contracts/USER.md
```markdown
# POC user
This is an isolated local runtime test.
```
Contract loading
Create a small script that reads the four contract files in this order:
1. CONSTITUTION.md
2. STANDARDS.md
3. SOUL.md
4. USER.md
Join them with clear file separators.
Write the generated system prompt to:
```text
/var/lib/mosaic/system-prompt.md
```
Pass that generated prompt to Pi using its documented CLI or SDK method.
Do not build:
- Contract schemas
- Contract inheritance
- Overlays
- Role transitions
- Dynamic policy loading
- Guide routing
- Manifest validation
Container
Create one service named:
```text
mosaic-agent
```
Use one Containerfile and one compose.yaml.
Requirements:
- Use a maintained Node.js base image.
- Run as a non-root user.
- Install a pinned Pi package version.
- Copy the local contract fixtures into /opt/mosaic/contracts.
- Do not copy credentials into the image.
- Do not mount the Docker socket.
- Do not mount either live Mosaic directory.
- Do not add a database, web server, queue, or second container.
- The container may run as a one-shot command. It does not need to remain running.
### Authentication
Use Pi's documented authentication mechanism.
Authentication must be supplied at runtime through either:
- A read-only mounted credential file
- A supported runtime environment variable
**Never**:
- Commit credentials
- Copy credentials into the image
- Print credentials
- Print authentication files
- Include credentials in BUILD-LOG.md
- Store credentials under the project directory
Provide .env.example only for non-secret settings such as model or provider names.
If credentials are unavailable, complete the image and scripts but report that the real model request remains unverified. Do not fake the response.
### Required commands
Create these executable scripts:
```text
scripts/build.sh
scripts/hello.sh
scripts/verify.sh
scripts/reset.sh
```
### scripts/build.sh
Build the container image using Docker Compose.
### scripts/hello.sh
Run the mosaic-agent service as a one-shot container.
Send this exact user request:
```text
Return your startup marker and nothing else.
```
The request must not contain MOSAIC_HELLO_OK.
Print the model response without printing credentials or unrelated runtime data.
### scripts/verify.sh
Run the complete test.
**It must**:
1. Build or confirm the image is built.
2. Run the agent request.
3. Remove surrounding whitespace from the response.
4. Compare the response with MOSAIC_HELLO_OK.
5. Exit 0 only when they match exactly.
6. Exit nonzero with a clear error when they do not match.
### scripts/reset.sh
Delete generated POC state only when all checks pass:
1. The resolved path is exactly /home/jwoltje/.mosaic-dev.
2. The path is not a symbolic link.
3. The directory contains a .mosaic-poc-root ownership marker created by this project.
Refuse to delete anything if a check fails.
## Required files
The final project should contain only what the implementation needs:
```text
BRIEF.md
BUILD-LOG.md
README.md
LAYERS.md
Containerfile
compose.yaml
package.json
package-lock.json
.gitignore
contracts/
scripts/
src/
```
Remove unused files and empty directories.
Build log
Create BUILD-LOG.md.
Treat it as append-only.
Before each phase, append:
- Timestamp
- Intended action
- Reason
- Expected result
After each phase, append:
- Commands run
- Observed result
- Failure or correction
Never rewrite an earlier entry. Add a correction as a new entry.
Do not record credentials.
Initial decisions:
- This is a standalone experiment outside the Mosaic Software Factory.
- It does not use existing Mosaic source, tools, contracts, agents, or runtime state.
- The first proof uses one Pi agent and four small local contract files.
- The only required model result is MOSAIC_HELLO_OK.
- Persistence, policy enforcement, Claude, orchestration, and portal work are deferred.
## Acceptance criteria
The experiment passes when:
1. scripts/build.sh exits 0.
2. The image contains the four local contract files.
3. The image contains no credentials.
4. The container has no mounts from ~/.mosaic or ~/.config/mosaic.
5. scripts/hello.sh performs a real model request.
6. The request does not contain the expected marker.
7. The agent returns exactly MOSAIC_HELLO_OK.
8. scripts/verify.sh exits 0.
9. Changing the expected value makes scripts/verify.sh exit nonzero.
10. scripts/reset.sh refuses unsafe paths.
11. Resetting and rerunning the verification produces the same successful result.
## Deferred layers
Document these in LAYERS.md. Do not implement them.
- L0: Container builds and returns MOSAIC_HELLO_OK.
- L1: Persist and resume a named Pi session.
- L2: Add a fixed tool permission policy.
- L3: Load full versioned contract bundles.
- L4: Add Claude as a second runtime.
- L5: Add multiple agents and communication.
- L6: Add orchestration, knowledge storage, and portal features.
## Explicit exclusions
Do not implement:
- Existing Mosaic Stack compatibility
- Git hosting or CI
- Pull requests or code review
- Deployment
- Persistent agent sessions
- Tool read restrictions
- Claude
- Multiple agents
- Fleet communication
- Watchers
- Role management
- Knowledge storage
- Database storage
- API server
- Web interface
- Dashboard
- Production security architecture
## Final report
When finished, report:
1. Files created.
2. Pi package version.
3. Exact build command.
4. Exact verification command.
5. Verification output with credentials removed.
6. Whether the real model request passed.
7. Any remaining failure.
8. Anything implemented beyond this brief.
Do not describe the experiment as production-ready.
-306
View File
@@ -1,306 +0,0 @@
# BUILD-LOG
Append-only build log for the Minimal Mosaic Stack container proof of concept.
Each phase records the plan before it runs and the observed result after it runs.
No credentials are recorded in this file.
---
## Phase 1: Pi package discovery
### Entry 1.1 — before
- Timestamp: 2026-02-02 (session start, local)
- Intended action: Locate the current package documentation for `@earendil-works/pi-coding-agent`, determine the current version, the supported noninteractive command, the custom system prompt mechanism, and the documented container authentication method.
- Reason: The brief forbids guessing CLI flags, authentication paths, or SDK methods; all runtime commands must be derived from the package documentation.
- Expected result: Documented answers for all five discovery questions, recorded below, with the Pi package version pinned in the project.
### Entry 1.2 — after
- Timestamp: 2026-02-02
- Commands run:
- Read `README.md` of the locally installed `@earendil-works/pi-coding-agent` package
- Read `docs/containerization.md`, excerpts of `docs/models.md` and `docs/providers.md`
- `grep '"version"' .../pi-coding-agent/package.json`
- `npm view @earendil-works/pi-coding-agent version`
- Inspected host auth store structure (keys only, values never printed)
- Observed result:
1. **Package documentation**: full docs ship inside the installed package (`README.md` plus a `docs/` directory including `docs/containerization.md`).
2. **Current package version**: `0.84.4` — the locally installed version and the npm registry latest are identical. Selected version to pin: `0.84.4`.
3. **Supported noninteractive command**: `pi -p` / `pi --print` — "Print response and exit". Documented in the CLI Reference. Print mode also merges piped stdin into the initial prompt (not used here).
4. **Custom system prompt**: documented CLI flag `--system-prompt <text>` — "Replace default prompt (context files and skills still appended)". Because the generated contract prompt must fully control behavior, the run also passes the documented discovery-off switches (`--no-context-files`, `--no-skills`, `--no-extensions`, `--no-prompt-templates`, `--no-themes`) so nothing is appended from ambient files. (`APPEND_SYSTEM.md` / `.pi/SYSTEM.md` file conventions exist but the CLI flag is the documented noninteractive path.)
5. **Documented container authentication**: `docs/containerization.md` documents the "Plain Docker" pattern: run the whole `pi` process in the container and pass provider API keys at runtime via environment variables (example: `docker run -e ANTHROPIC_API_KEY ...`). The documented alternative credential store is `auth.json` written by `/login` under the pi agent directory (`~/.pi/agent/auth.json`).
- Decision: supply the credential at runtime as a **read-only mounted credential file** (host `auth.json`, which contains a static API-key entry for the built-in `zai` provider) mounted at the container pi agent directory, and additionally allow the documented environment-variable path (`ZAI_API_KEY` / `ANTHROPIC_API_KEY`) as an alternative. Provider and model are non-secret settings supplied via `.env` (`PI_PROVIDER=zai`, `PI_MODEL=glm-5.3-flash`).
- Failure or correction: none. Host check confirmed no API-key environment variables are exported on the host, so the read-only auth.json mount is the working runtime credential path for this experiment.
---
## Phase 2: Project scaffold
### Entry 2.1 — before
- Timestamp: 2026-02-02
- Intended action: Create the contract fixtures (exact brief contents), the contract loader (`src/load-contracts.sh`), the one-shot agent runner (`src/run-agent.sh`), the four required scripts (`scripts/build.sh`, `hello.sh`, `verify.sh`, `reset.sh`), `Containerfile`, `compose.yaml`, pinned `package.json` + `package-lock.json`, `.gitignore`, `README.md`, `LAYERS.md`.
- Reason: Implement exactly the file set the brief requires, with no extra machinery (no schemas, overlays, manifests, or policy loading).
- Expected result: A complete project whose only remaining unknown is whether the pinned image builds and the real model request returns `MOSAIC_HELLO_OK`.
### Entry 2.2 — after
- Timestamp: 2026-02-02
- Commands run: file creation; `npm install --package-lock-only --ignore-scripts` to generate the lockfile from the pinned dependency.
- Observed result: All files created; `package-lock.json` pins `@earendil-works/[email protected]` (exact, no range).
- Failure or correction: none.
---
## Phase 3: Container image build
### Entry 3.1 — before
- Timestamp: 2026-02-02
- Intended action: Run `scripts/build.sh` (Docker Compose build) to produce image `mosaic-poc-agent:0.84.4` from `node:24-bookworm-slim` with the pinned Pi, the four contract fixtures at `/opt/mosaic/contracts`, and a non-root user (uid/gid 1000).
- Reason: Phase 1 of the required proof path; `node:24-bookworm-slim` is the maintained base image used in Pi's own documented containerization example.
- Expected result: `docker compose build` exits 0 and the image contains the contracts, the runner scripts, and the pinned `pi` binary, with no credentials baked in.
### Entry 3.2 — after
- Timestamp: 2026-02-02
- Commands run: `scripts/build.sh`; `docker run --rm mosaic-poc-agent:0.84.4 --version`; `id` via `--entrypoint`; contract listing; credential file scan.
- Observed result:
- Build exit 0; image tagged `mosaic-poc-agent:0.84.4`.
- `pi --version` inside the image reports `0.84.4` (and this run also executed the contract loader successfully, writing `/var/lib/mosaic/system-prompt.md`).
- Container user is `uid=1000(node) gid=1000(node)` — non-root.
- All four contract files present at `/opt/mosaic/contracts` with read-only permissions (0555).
- Credential scan: no `auth.json` or other auth files exist in the image; `/home/node/.pi/agent/` is empty in the image.
- Failure or correction:
1. First build failed: Docker Compose expects `Dockerfile` by default; fixed by setting `build.dockerfile: Containerfile` in `compose.yaml`.
2. Second build failed: `useradd` exit 4 (uid 1000 already exists) because the maintained node image ships a `node` user at uid/gid 1000. Fixed by reusing the built-in `node` user (same 1000:1000 host mapping) instead of creating a duplicate `mosaic` user; container paths updated from `/home/mosaic/...` to `/home/node/...` in `Containerfile`, `compose.yaml`, `README.md`, `.env.example`.
---
## Phase 4: Runtime verification
### Entry 4.1 — before
- Timestamp: 2026-02-02
- Intended action: Run `scripts/hello.sh` (one-shot request: "Return your startup marker and nothing else."), then `scripts/verify.sh` (exact-match gate against `MOSAIC_HELLO_OK`), then the negative test (`EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh` must exit nonzero), then the `scripts/reset.sh` safety tests and a final rerun after reset.
- Reason: Phases 27 of the required proof path plus acceptance criteria 511.
- Expected result: hello prints only the marker; verify exits 0; negative test exits nonzero; reset refuses unsafe paths and succeeds on the real path; rerun after reset reproduces the success.
### Entry 4.2 — after
- Timestamp: 2026-02-02
- Commands run: `scripts/hello.sh`; `scripts/verify.sh`; `EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh`; `scripts/reset.sh` (refusal tests: missing marker, symlink with canary file, then real reset, then missing dir); `scripts/build.sh && scripts/verify.sh` after reset; `docker compose config` mount inspection.
- Observed result:
- `hello.sh`: stdout exactly `MOSAIC_HELLO_OK` — a real model request (provider `zai`, model `glm-5.3-flash`, auth via the read-only mounted auth.json credential file). The request string contains no marker.
- `verify.sh`: `PASS: response matches expected marker`, exit 0.
- Negative test: `FAIL: response does not match expected marker` (expected `MOSAIC_NOT_OK`, actual `MOSAIC_HELLO_OK`), exit 1.
- `reset.sh` refusal tests: missing marker → exit 1, nothing deleted; symlink (with canary file at the target) → exit 1, canary survived; real path with marker → removed, exit 0; missing dir → "nothing to remove", exit 0.
- Rerun after reset: build + verify → PASS, exit 0 (criterion 11).
- Resolved compose mounts: only `/home/jwoltje/.mosaic-dev → /var/lib/mosaic` (rw) and `~/.pi/agent/auth.json → /home/node/.pi/agent/auth.json` (read-only). No `~/.mosaic` or `~/.config/mosaic` mounts, no Docker socket.
- Failure or correction:
1. First hello run: the contract loader's status line was printed on stdout, mixing runtime data into the model response stream and contaminating the exact-match capture. Fixed by sending the loader's status message to stderr (`src/load-contracts.sh`), rebuilt the image, reran: stdout is exactly the model response.
- Credential check: no credential material appears in this log, in hello/verify output, or in the image (image scan found no auth files).
## Result
All 11 acceptance criteria demonstrated. The real model request passed.
---
## Phase 5: Configuration-driven Hello World (M1)
### Entry 5.1 — before
- Timestamp: 2026-09-03
- Intended action: Make the container POC configuration-driven. Baseline committed and tagged `poc-container-hello-v0`. Milestone M1 tracked in Gitea (issues #1-#4): (T1) config module with idempotent bootstrap and strict v1 validation; (T2) wire scripts and compose to config.json with fail-closed behavior; (T3) sandboxed config selftests; (T4) E2E verification and documentation.
- Reason: Per docs/plans/2026-09-02_atomic-mosaic-foundation.md — config.json must be the sole discovery entry point; updates and runs must never corrupt or invent configuration.
- Expected result: All M1 acceptance criteria pass; Hello World reproducible from configuration alone.
### Entry 5.2 — after
- Timestamp: 2026-09-03
- Commands run: `scripts/test-config.sh` (20 cases); fail-closed checks (compose without launcher env, verify/reset with missing config); `scripts/bootstrap.sh`; config-driven `scripts/hello.sh`, `scripts/verify.sh`, negative marker test, sandboxed reset symlink refusal (canary survived), real reset + bootstrap + build + verify; config checksum comparison across the entire flow.
- Observed result:
- Config selftests: 20 passed, 0 failed.
- Fail-closed confirmed: compose exits 1 without launcher env; verify/reset exit 1 on missing config before any mutation.
- Bootstrap created `~/.config/mosaic-dev/config.json` exclusively; second run validated without rewriting (content + mtime unchanged).
- Config-driven hello/verify returned exactly `MOSAIC_HELLO_OK`; verify exit 0; negative marker test exit 1.
- Reset refused symlinked dataRoot; canary file survived; real reset removed only the configured data root.
- config.json checksum unchanged across hello/verify/reset/bootstrap/build/verify.
- Failure or correction:
1. Selftest harness bug: `cfg` helper invoked without a body for the symlink case (`$2: unbound variable`). Fixed in the harness; product code unaffected.
2. E2E rerun-after-reset failure: `verify.sh` did not ensure the configured data root existed before the container mount. With the data root absent, Docker auto-created the host path as root:root, and the container's uid-1000 user could not write the generated system prompt. Fixed by calling `bootstrap_runtime_dir` in `verify.sh`; also hardened it to fail with a clear message when the data root exists but is not writable (root-owned leftover). Clean-slate E2E rerun: all steps green.
- Credential check: no credential material in config, scripts, logs, or test output.
## Result (M1)
Configuration-driven Hello World verified. `main` merged with M1 and tagged `config-hello-v1`.
---
## Phase 6: Mission and task abstraction (M2)
### Entry 6.1 — before
- Timestamp: 2026-09-03
- Intended action: Add the first mission/task layer, host-side only (Gitea milestone M2, issues #6-#9): strict v1 schemas for missions and tasks, a task runner executing through the proven config-driven container path, immutable write-once run records under `<dataRoot>/runs/`, `expectExact` gating, timeouts, selftests, fixtures, and docs.
- Reason: The foundation plan's following layer — mission (objective + directives), task (bounded unit), run (one attempt), result (immutable evidence) — must exist as data and records before any policy or multi-agent work.
- Expected result: `scripts/run-task.sh tasks/hello-marker.json` succeeds with exactly `MOSAIC_HELLO_OK`; wrong expectations fail; every run leaves an immutable record; configuration remains untouched.
### Entry 6.2 — after
- Timestamp: 2026-09-03
- Commands run: `scripts/test-task.sh` (18 cases incl. live runs); `scripts/run-task.sh validate` / `run` on committed fixtures; `node scripts/mosaic-task.mjs list`; config checksum comparison across runs.
- Observed result:
- Selftests: 18 passed, 0 failed (schema negatives; live exact-marker success; wrong expectExact fails; distinct run dirs; result.json contents; list).
- Fixture run: status `succeeded`, response exactly `MOSAIC_HELLO_OK`, mission snapshot recorded.
- Run records written once under `<dataRoot>/runs/r-<utcstamp>-<rand>/`; reruns never clobber.
- Failure or correction: none this phase.
- Credential check: no credential material in task data, run records, or logs.
## Result (M2)
Mission/task layer verified end-to-end. `main` merged with M2 and tagged `mission-task-v1`.
---
## Phase 7: Release model and safe updates (M3)
### Entry 7.1 — before
- Timestamp: 2026-09-03
- Intended action: Add the release substrate (Gitea milestone M3, issues #10-#13): RELEASE file single-sources the version (0.0.X line per owner direction), image tags derive from it, scripts/release.sh provides package/activate/rollback/status, activation is health-gated by the M2 task runner, pointer + append-only log under <dataRoot>/state/.
- Reason: The owner's top invariant — updates must never corrupt a working installation — needs a mechanism, not a convention: gate-then-flip with recorded history and rollback.
- Expected result: Update, refusal, and rollback drills all green with config checksums unchanged.
### Entry 7.2 — after
- Timestamp: 2026-09-03
- Commands run: scripts/test-release.sh (14 cases); recorded drills: update (0.0.3 -> 0.0.4 package+activate+verify), fault-injected refusal, rollback to 0.0.3.
- Observed result:
- Selftests: 14 passed, 0 failed.
- Update drill: packaged and activated r0.0.4 after exact-marker health gate; verify green under the new tag; config checksum unchanged.
- Refusal drill: health-gate fault injection -> activation refused (exit 1), pointer untouched, refusal appended to the log.
- Rollback drill: health-gated rollback to r0.0.3; pointer restored; log records package/activate/refused/rollback history append-only.
- Failure or correction:
1. release.sh initially failed with missing state/ directory (no mkdir before pointer/log writes); fixed.
2. Selftest harness mutated the repo RELEASE and restored the mutated copy (mv-back bug) plus a second trap replacing the first; fixed with inline backup restore and one self-healing exit trap. Product code unaffected.
- Credential check: no credential material in release state, logs, or drills.
## Result (M3)
Release model and safe updates verified by drills. `main` merged with M3 and tagged `release-model-v1`.
---
## Phase 8: Runtime adapter seam (M4)
### Entry 8.1 — before
- Timestamp: 2026-09-03
- Intended action: Formalize the harness boundary (Gitea milestone M4, issues #16-#19): documented adapter contract under /opt/mosaic/adapters/<name>/adapter.sh; run-agent.sh becomes a dispatcher; pi extracted unchanged; deterministic mock adapter for provider-free seam tests; config gains optional execution.adapter (default pi, configVersion unchanged); mission directives gain their sanctioned injection point via the run snapshot; RELEASE bumps to 0.0.5 with a health-gated activation.
- Reason: Future harnesses (Claude, Codex, OpenCode) must be additive — one directory each — and mission content needs a single sanctioned path into the runtime.
- Expected result: All suites green including new deterministic seam cases; 0.0.5 activated by health gate; mission-bearing run recorded.
### Entry 8.2 — after
- Timestamp: 2026-09-03
- Commands run: scripts/test-config.sh; scripts/test-task.sh; scripts/test-release.sh; manual seam drills (mock verbatim, unknown/traversal adapter refusal); mission injection checks; release package + activate for 0.0.5.
- Observed result:
- Config suite 24/24 (adapter default/validation/env export).
- Task suite 24/24 including deterministic mock cases (gate pass, expect-mismatch with reason, unknown adapter fail-closed) and mission injection asserted by prompt content.
- Release suite 14/14; image mosaic-poc-agent:0.84.4-r0.0.5 packaged and activated via exact-marker health gate.
- Mission directives now flow: task -> run snapshot -> container env -> generated prompt MISSION (runtime) section.
- Failure or correction:
1. Selection authority settled: load_config always exports MOSAIC_ADAPTER from config; environment overrides for scripts are therefore not a supported selection path (by design).
2. Selftest harness: three authoring defects fixed (helpers used before definition; one config file reused across cases leaking adapter state; a static mission fixture asserted against distinctive seam directives; plus an accidentally duplicated live block removed).
- Credential check: no credential material in adapters, prompts, run records, or logs.
## Result (M4)
Adapter seam verified; harness boundary is now additive by construction. `main` merged with M4 and tagged `adapter-seam-v1`.
---
## Phase 9: Workspaces + capability envelope (M5)
### Entry 9.1 — before
- Timestamp: 2026-09-03
- Intended action: Optional task workspace (absent / ":run" ephemeral / named persistent) and capabilities.tools allowlist (pi built-ins); runner plumbing via MOSAIC_WORKSPACE/MOSAIC_TOOLS; pi adapter maps to cwd + --tools; mock adapter logs delivered MOSAIC_* vars for deterministic assertions (Gitea #20, #21).
- Reason: Agents that only answer text cannot do work; the workspace+tools pair is the smallest real capability step, bounded by the container.
- Expected result: plumbing asserted via run-record stderr; live pi writes a host-visible file.
### Entry 9.2 — after
- Timestamp: 2026-09-03
- Commands run: build; mock plumbing run; validate negatives; full task suite; live workspace demo.
- Observed result: task suite 32/32; MOSAIC_WORKSPACE/MOSAIC_TOOLS asserted in run record; dataRoot/workspaces/<name> created host-side; live pi used bash to write proof.txt into the demo workspace (host-visible).
- Failure or correction: (1) batch edit dropped SUPPORTED_TOOLS const (runtime ReferenceError, exit 1 instead of 2) — restored; (2) mock env dump used `export` which this dash prints as `export K='v'` — switched to `env`; (3) selftest fed a mismatching mock response to the plain-task case — test bug, fixed.
## Phase 10: Named sessions (M6)
### Entry 10.1 — before
- Timestamp: 2026-09-03
- Intended action: Optional task.session name -> persistent session dir dataRoot/sessions/<name> via pi --session-dir; resume most recent with -c when present; isolation per name; teach/recall demo fixtures (Gitea #22, #23).
- Reason: L1 persistence is the prerequisite for any multi-step agent work.
- Expected result: session dir populated after first run; second run recalls taught context.
### Entry 10.2 — after
- Timestamp: 2026-09-03
- Commands run: build; mock plumbing run; live teach/recall E2E; task suite.
- Observed result: task suite 32/32; teach run replied REMEMBERED and session JSONL persisted host-side; recall run resumed (-c) and answered exactly 'mosaico'; single continued session file (no duplicate sessions).
- Failure or correction: none. Design note: ephemeral (--no-session) remains the default when no session is declared.
## Phase 11: Operator ergonomics (M7)
### Entry 11.1 — before
- Timestamp: 2026-09-03
- Intended action: mosaic-task.mjs show <runId> (full record + snapshots + artifacts, traversal-safe), list with workspace/session columns, RELEASE -> 0.0.6, package + health-gated activate, docs (Gitea #24).
- Reason: Run records are only as valuable as they are inspectable; release activation closes the loop on container-content changes.
- Expected result: show works for real/missing/traversal ids; suites green; 0.0.6 active.
### Entry 11.2 — after
- Timestamp: 2026-09-03
- Commands run: build; show on real/missing/traversal ids; full sweep; release package + activate.
- Observed result: config 24/24, task 32/32, release 14/14, verify PASS; 0.0.6 packaged and activated via exact-marker health gate.
- Failure or correction: showRun initially rejected valid run ids (lowercase-only regex vs uppercase timestamp) and crashed on missing ids (uncaught readdir) — both fixed and covered.
## Autonomous run result
M5 tagged `workspace-capabilities-v1`, M6 tagged `sessions-v1`, M7 tagged `operator-ergonomics-v1`; release 0.0.6 active. Tracker: docs/plans/2026-09-03_autonomous-run.md.
---
## Phase 12: Conductor loop — self-orchestration (M8)
### Entry 12.1 — before
- Timestamp: 2026-09-03
- Intended action: Stand up the poor-man orchestration loop per docs/plans/CONDUCTOR.md: conductor (host, holds git) mirrors the repo into a worker workspace, dispatches a headless pi worker (session worker-1, tools read/write/edit/bash) to implement retry <runId>, reviews the diff, integrates, verifies (Gitea #25, #26, #27).
- Reason: The owner asked for circular task processing with agent workers; the stack now has every primitive needed — this proves it on the stack itself.
- Expected result: worker-authored retry merged with suites green and a live retry verified.
### Entry 12.2 — after
- Timestamp: 2026-09-03
- Commands run: repo mirror clone; worker dispatch (tasks/worker-retry.json); diff review; apply; live retry; refinement dispatch (tasks/worker-retry-refine.json); second review; reverse+reapply combined patch; conductor interpolation fix; live retry; full sweep.
- Observed result:
- Worker round 1: implemented retry correctly per spec in 2m28s; diff reviewed clean.
- Live retry exposed a spec gap (direct invocation lacks launcher env exports).
- Worker round 2 (same session, 59s): made spawnEnv self-sufficient, but used PI_* names where compose interpolates MOSAIC_*.
- Conductor hotfix: 3-line rename to MOSAIC_PROVIDER/MOSAIC_MODEL/MOSAIC_DATA_ROOT (too trivial for a worker round).
- Final: live retry succeeded (replied REMEMBERED, new run recorded); all suites green.
- Failure or correction: three rounds total — one spec gap (conductor), one naming mismatch (worker), one trivial rename (conductor). Each was caught by mechanical verification (run record stderr), never by hope.
- Attribution: feature authored by headless pi worker (glm-5.3-flash) in sessions worker-1; conductor reviewed, integrated, and hotfixed.
## Result (M8)
Conductor loop proven end-to-end on the stack itself. `main` merged with M8; release 0.0.6 remains active (retry is host-side only, no image change).
+45
View File
@@ -0,0 +1,45 @@
# CLAUDE.md — Mosaic Stack
## Project
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
## Stack
- **API**: NestJS + Fastify adapter (`apps/gateway`)
- **Web**: Next.js 16 + React 19 (`apps/web`)
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
- **Auth**: BetterAuth (`packages/auth`)
- **Agent**: Pi SDK (`packages/agent`, `packages/cli`)
- **Queue**: Valkey 8 (`packages/queue`)
- **Build**: pnpm workspaces + Turborepo
- **CI**: Woodpecker CI
- **Observability**: OpenTelemetry → Jaeger
## Commands
```bash
pnpm typecheck # TypeScript check (all packages)
pnpm lint # ESLint (all packages)
pnpm format:check # Prettier check
pnpm test # Vitest (all packages)
pnpm build # Build all packages
# Database
pnpm --filter @mosaic/db db:push # Push schema to PG (dev)
pnpm --filter @mosaic/db db:generate # Generate migrations
pnpm --filter @mosaic/db db:migrate # Run migrations
# Dev
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
pnpm --filter @mosaic/gateway exec tsx src/main.ts # Start gateway
```
## Conventions
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
- DTOs in `*.dto.ts` files at module boundaries
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
- All three gates must pass before push: typecheck, lint, format:check
-43
View File
@@ -1,43 +0,0 @@
# Minimal Mosaic Stack POC agent image.
# Base: maintained Node.js image (same family as Pi's documented
# containerization example in docs/containerization.md).
FROM node:24-bookworm-slim
# Tools Pi's documented container image expects (bash, CA certs, git, ripgrep).
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Non-root user: the maintained node image ships a 'node' user at
# uid/gid 1000, which matches the host user that owns the runtime
# state directory mounted at /var/lib/mosaic. It is reused as-is.
# Pinned Pi install: package.json pins the exact version and
# package-lock.json is installed with npm ci. No unversioned installs.
WORKDIR /opt/app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
# Immutable contract fixtures (required location), runtime scripts, and
# runtime adapters.
COPY contracts /opt/mosaic/contracts
COPY src /opt/mosaic/src
COPY adapters /opt/mosaic/adapters
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
&& chmod 0555 /opt/mosaic/adapters /opt/mosaic/adapters/*/adapter.sh
# Writable state, workspace, and pi agent directory (auth.json is
# bind-mounted read-only at runtime; nothing is copied into the image).
RUN mkdir -p /var/lib/mosaic /workspace /home/node/.pi/agent \
&& chown -R node:node /var/lib/mosaic /workspace /home/node /opt/app
USER node
WORKDIR /workspace
ENV HOME=/home/node \
PATH="/opt/app/node_modules/.bin:${PATH}" \
PI_OFFLINE=1
# One-shot agent: args form the user request (default is the startup
# verification request defined in compose.yaml).
ENTRYPOINT ["/opt/mosaic/src/run-agent.sh"]
-50
View File
@@ -1,50 +0,0 @@
# LAYERS
Deferred capability layers for the Mosaic experiment. Only L0 is implemented by
this proof of concept; everything below it is documented here and deliberately
not implemented (see BRIEF.md, "Explicit exclusions").
## L0 — Implemented: container returns MOSAIC_HELLO_OK
One image (`mosaic-poc-agent:0.84.4`, built on `node:24-bookworm-slim`, non-root,
pinned Pi) runs one Pi agent one-shot. Four immutable local contract files are
loaded in fixed order into the generated system prompt
(`/var/lib/mosaic/system-prompt.md`). One real model request is sent
noninteractively; the response must equal `MOSAIC_HELLO_OK` exactly or the
verification exits nonzero. Authentication is supplied at runtime only
(read-only mounted pi auth file, or a provider API key environment variable).
## L1 — Deferred: persist and resume a named Pi session
Keep a named Pi session across container runs (`--name`, session storage under
`/var/lib/mosaic`), resume it with the documented session flags, and verify
state survives a container restart.
## L2 — Deferred: fixed tool permission policy
Add a fixed allow/deny policy for Pi tools (e.g. restricting built-in tools via
documented `--tools` / `--exclude-tools` or an extension-based permission gate),
so contract files can constrain what the agent may do, not just what it says.
## L3 — Deferred: load full versioned contract bundles
Replace the four static fixtures with versioned contract bundles: bundle
manifests, contract versions, and deterministic ordering/hashing, loaded from
an immutable bundle artifact instead of files copied at image build time.
## L4 — Deferred: Claude as a second runtime
Add a second runtime (Claude) alongside the Pi agent in the same container
stack, behind the same contract-loading path, to compare behavior across
runtimes.
## L5 — Deferred: multiple agents and communication
Run several named agents with defined roles and a communication channel between
them (message passing or shared state under `/var/lib/mosaic`).
## L6 — Deferred: orchestration, knowledge storage, and portal features
Fleet-level orchestration, knowledge storage, monitoring, and portal UI on top
of L1-L5. This is where the existing Mosaic Stack concepts would be re-evaluated
from first principles.
-216
View File
@@ -1,216 +0,0 @@
# Minimal Mosaic Stack container POC
Standalone experiment, not part of the Mosaic Stack repository or Software Factory.
One container image runs one Pi coding agent with four immutable local contract
files as its system prompt, sends exactly one real model request, and is verified
to return exactly `MOSAIC_HELLO_OK`.
## Layout
```text
BRIEF.md requirements for the original container proof
BUILD-LOG.md append-only build/verification log
LAYERS.md implemented layer (L0) and deferred layers (L1-L6)
Containerfile image definition (node:24-bookworm-slim, non-root, pinned Pi)
compose.yaml one service: mosaic-agent (one-shot; configured via env)
package.json pins @earendil-works/pi-coding-agent at exactly 0.84.4
package-lock.json resolved lockfile used by npm ci in the image
.env.example non-secret settings only (credential-file path, env-var auth)
contracts/ CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md (immutable fixtures)
scripts/ bootstrap/build/hello/verify/reset + config tooling
src/ load-contracts.sh, run-agent.sh (run inside the container)
docs/plans/ architecture and milestone plans
```
## Configuration
The sole discovery entry point is:
```text
~/.config/mosaic-dev/config.json
```
Created only by the explicit, idempotent bootstrap:
```bash
scripts/bootstrap.sh # create-if-absent; validates existing config, never rewrites
```
Minimal shape (`configVersion` 1):
```json
{
"configVersion": 1,
"environment": "development",
"dataRoot": "/home/jwoltje/.mosaic-dev",
"execution": {
"backend": "docker",
"provider": "zai",
"model": "glm-5.3-flash"
}
}
```
Rules enforced by `scripts/mosaic-config.mjs`:
- Unknown keys, unsupported versions/backends, and malformed JSON exit nonzero; nothing is modified.
- `dataRoot` must be absolute, canonical, and must not be or contain the home or configuration directory.
- Validation failures never touch config, state, or images.
- `scripts/test-config.sh` runs the sandboxed config selftests (no Docker required).
Run paths (`build/hello/verify/reset`) fail closed when configuration is missing or invalid; they never invent it.
## Missions & tasks (M2)
Missions and tasks are validated JSON data (strict schemas, version-pinned). The M2 layer is host-side only: mission directives are recorded for provenance but do not yet reach the runtime system prompt (capability/policy layer comes later).
```text
missions/hello.json objective + directives (missionVersion 1)
tasks/hello-marker.json prompt + optional mission ref + expectExact + timeout
<dataRoot>/runs/r-<id>/ immutable run record: task.json, mission.json,
stderr.txt, result.json (all write-once)
```
Usage:
```bash
scripts/run-task.sh validate tasks/hello-marker.json # strict validation, writes nothing
scripts/run-task.sh run tasks/hello-marker.json # execute; result recorded under dataRoot/runs
scripts/mosaic-task.mjs list # list runs and statuses
scripts/test-task.sh # selftests (schema negatives + live runs)
```
A run exits 0 only when its expectation is met (`expectExact` match); mismatches, nonzero agent exits, and timeouts record `status: failed` in `result.json` and exit 1. Each run gets a unique directory — rerunning never rewrites history.
## Release model (M3)
`RELEASE` single-sources the release version (0.0.X until declared stable); the image tag derives from it plus the pinned Pi version. Activation is health-gated and every event is recorded:
```bash
scripts/release.sh package # build + tag the release image
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
scripts/release.sh activate --fault-injection # prove the refusal path (drills only)
scripts/release.sh rollback # health-gated return to the previous release
scripts/release.sh status # release, tag, active pointer, recent log
scripts/test-release.sh # release selftests
```
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
A failed health check never activates; the previously active release remains deployed. Updating the software therefore cannot corrupt the running installation: package beside, gate, then flip. Verified by the update/refusal/rollback drills in BUILD-LOG Phase 7.
## Runtime adapters (M4)
The harness boundary is formalized: everything upstream (config, contracts, missions, tasks, run records) is harness-agnostic; everything inside an adapter belongs to one runtime.
```text
adapters/<name>/adapter.sh env in: MOSAIC_SYSTEM_PROMPT_FILE, MOSAIC_REQUEST,
MOSAIC_PROVIDER, MOSAIC_MODEL
stdout: response only; stderr: diagnostics
```
- Selection: `execution.adapter` in config.json (optional; `pi` default; allowlist `pi`, `mock`)
- `pi` — pinned Pi CLI, noninteractive print mode, ambient discovery off
- `mock` — deterministic test adapter; never for real verification
- Mission directives have a sanctioned injection point: when a task references a mission, the task runner mounts the run snapshot and the generated prompt gains a `MISSION (runtime)` section (objective + directives) after the four immutable contracts
- Adding a harness (Claude, Codex, OpenCode) later means adding one directory — no orchestrator changes
See `adapters/README.md` for the full contract.
## Workspaces, capabilities, sessions (M5/M6)
Optional task fields extend what an agent can do — all defaulting to the previous behavior:
```json
{
"workspace": "demo", // ":run" ephemeral, or persistent dataRoot/workspaces/<name>
"capabilities": { "tools": ["bash", "read"] }, // pi tool allowlist; absent = no tools
"session": "demo" // persistent session at dataRoot/sessions/<name>
}
```
- The adapter runs inside the workspace; files it writes are host-visible (`dataRoot/workspaces/<name>`).
- Sessions persist via pi's documented `--session-dir`; a follow-up run in the same session resumes the conversation (`-c`) and can recall prior context. Distinct names never share state. Ephemeral (`--no-session`) remains the default when no session is declared.
- Selection authority: config for adapter/provider/model; the task file for workspace/capabilities/session.
Inspect anything:
```bash
node scripts/mosaic-task.mjs list # runs with task/workspace/session columns
node scripts/mosaic-task.mjs show <runId> # full record + snapshots + artifacts
```
Demo fixtures: `tasks/workspace-demo.json`, `tasks/session-demo-1.json` + `tasks/session-demo-2.json`.
See `docs/plans/2026-09-02_atomic-mosaic-foundation.md` for the full plan.
Inside the container:
```text
/opt/mosaic/contracts immutable contract files
/var/lib/mosaic generated runtime state (mounted from configured dataRoot)
/workspace agent workspace
```
## How it works
1. `scripts/build.sh` builds the release image (`mosaic-poc-agent:<pi>-r<release>`,
tag derived from `RELEASE` + the pinned Pi version) with Docker Compose.
2. On each run, `/opt/mosaic/src/load-contracts.sh` reads the four contract files
in fixed order (CONSTITUTION, STANDARDS, SOUL, USER), joins them with clear
separators, and writes `/var/lib/mosaic/system-prompt.md`.
3. `/opt/mosaic/src/run-agent.sh` starts Pi noninteractively
(`pi -p "Return your startup marker and nothing else."`) with
`--system-prompt "$(cat /var/lib/mosaic/system-prompt.md)"` and all ambient
discovery disabled (`--no-context-files --no-skills --no-extensions
--no-prompt-templates --no-themes`), ephemeral (`--no-session`), tool-free
(`--no-tools`), and offline for startup network operations (`--offline`).
4. `scripts/verify.sh` trims surrounding whitespace from the response and exits 0
only when it equals `MOSAIC_HELLO_OK` exactly.
## Usage
```bash
scripts/bootstrap.sh # create config.json if absent (idempotent)
scripts/build.sh # build the image
scripts/hello.sh # one-shot request; prints the model response
scripts/verify.sh # full gated test; exit 0 only on exact MOSAIC_HELLO_OK
scripts/run-task.sh # run a mission/task file (see Missions & tasks)
scripts/release.sh # package / activate / rollback / status (see Release model)
scripts/test-config.sh # fast config-layer selftests (no Docker)
scripts/test-task.sh # mission/task selftests (schema + adapter seam + live runs)
scripts/test-release.sh # release selftests
scripts/reset.sh # delete the configured data root (safety-checked)
```
Prove the failure path (acceptance criterion 9):
```bash
EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh # must exit nonzero
```
## Authentication
Pi's documented container authentication (see the package's
`docs/containerization.md`) is used, in this order:
1. **Read-only mounted credential file** (default): the host pi auth file
`~/.pi/agent/auth.json` is bind-mounted read-only to
`/home/node/.pi/agent/auth.json`. The host file holds a static API-key
entry for the built-in `zai` provider, so no token refresh writes are needed.
2. **Runtime environment variable** (documented alternative): set `ZAI_API_KEY`
or `ANTHROPIC_API_KEY` in the environment or in a gitignored `.env`; compose
passes them through. Pi's documented precedence applies.
Credentials are never committed, never copied into the image, and never printed.
`.env.example` contains non-secret settings only.
## Boundaries honored
- No mounts of `~/.mosaic` or `~/.config/mosaic`; no Docker socket mount.
- Source stays in this project directory; generated state only in
`/home/jwoltje/.mosaic-dev` (host) and `/var/lib/mosaic` (container).
- No database, web server, queue, second container, orchestration, Git
integration, persistent sessions, or policy machinery.
-1
View File
@@ -1 +0,0 @@
0.0.6
-54
View File
@@ -1,54 +0,0 @@
# Mosaic runtime adapters
An adapter is the entire harness-specific surface of the system. Everything
upstream of an adapter — configuration, contracts, missions, tasks, run
records — is harness-agnostic; everything inside an adapter may assume one
specific agent runtime.
## Contract
An adapter lives at:
```text
/opt/mosaic/adapters/<name>/adapter.sh
```
and must be executable. The dispatcher (`/opt/mosaic/src/run-agent.sh`)
selects it via `MOSAIC_ADAPTER` (default: `pi`) and execs it after the
system prompt has been generated.
**Inputs (environment):**
| Variable | Meaning |
|---|---|
| `MOSAIC_SYSTEM_PROMPT_FILE` | Absolute path to the generated system prompt (contracts + optional mission section). Read it; do not modify it. |
| `MOSAIC_REQUEST` | The exact user request text (may contain newlines). |
| `MOSAIC_PROVIDER` | Configured provider name. |
| `MOSAIC_MODEL` | Configured model id. |
Optional, adapter-specific (documented per adapter):
| Variable | Meaning |
|---|---|
| `MOSAIC_MOCK_RESPONSE` | mock only: the verbatim response to emit |
**Outputs:**
- `stdout`: the model response text — the only channel the orchestrator captures
- `stderr`: diagnostics (never credentials)
- exit `0`: success; nonzero: failure
## Rules
1. Adapters print ONLY the response on stdout. Status lines go to stderr.
2. Adapters never read configuration files; the resolved settings arrive via environment.
3. Adapters never write outside `/var/lib/mosaic`.
4. Adding an adapter requires: a new directory, the contract implementation, and
adding the name to the allowlist in `scripts/mosaic-config.mjs`.
## Included adapters
- `pi` — the pinned `@earendil-works/pi-coding-agent` CLI in noninteractive
print mode (`-p`), ambient discovery disabled, stdin detached.
- `mock` — deterministic echo of `MOSAIC_MOCK_RESPONSE`. Test-only: never use
it where a real model response is required.
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
# Mock adapter: deterministic response for seam tests. NEVER use where a
# real model response is required.
#
# Contract: see /opt/mosaic/adapters/README.md.
set -eu
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "mock adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2
# Deterministic plumbing evidence: which MOSAIC_* variables did the
# orchestrator actually deliver? (Auth secrets are not MOSAIC_-prefixed.)
(env | grep '^MOSAIC_' | sort) >&2 2>/dev/null || true
printf '%s\n' "${MOSAIC_MOCK_RESPONSE:-}"
-55
View File
@@ -1,55 +0,0 @@
#!/bin/sh
# Pi adapter: implements the Mosaic adapter contract for the pinned
# @earendil-works/pi-coding-agent CLI.
#
# Contract: see /opt/mosaic/adapters/README.md. stdout = response only.
set -eu
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
: "${PI_MODEL:?pi adapter: PI_MODEL is required}"
# Workspace (M5): run inside the provided workspace when present.
if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
mkdir -p "$MOSAIC_WORKSPACE"
cd "$MOSAIC_WORKSPACE"
fi
# Session (M6): persistent named session directory; resume the most recent
# session in that directory when one exists (pi documented flags).
# Default remains ephemeral (--no-session) when no session is declared.
SESSION_FLAGS="--no-session"
if [ -n "${MOSAIC_SESSION_DIR:-}" ]; then
mkdir -p "$MOSAIC_SESSION_DIR"
SESSION_FLAGS="--session-dir $MOSAIC_SESSION_DIR"
if [ -n "$(ls -A "$MOSAIC_SESSION_DIR" 2>/dev/null)" ]; then
SESSION_FLAGS="$SESSION_FLAGS -c"
fi
fi
# Capabilities (M5): explicit allowlist or no tools.
TOOLS_FLAG="--no-tools"
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
# All flags documented in the pi package README (CLI Reference):
# -p/--print noninteractive: print the response and exit
# --system-prompt replace the default prompt with the generated one
# --no-* no ambient context/skills/extensions/templates/themes
# --no-session ephemeral; TOOLS_FLAG per capabilities
# --offline no startup network operations (update checks/telemetry)
exec pi \
--offline \
--no-extensions \
--no-skills \
--no-prompt-templates \
--no-themes \
--no-context-files \
$TOOLS_FLAG \
$SESSION_FLAGS \
--provider "$PI_PROVIDER" \
--model "$PI_MODEL" \
--system-prompt "$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")" \
-p "$MOSAIC_REQUEST"
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@mosaic/gateway",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "dist/main.js",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/main.ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mariozechner/pi-ai": "~0.57.1",
"@mariozechner/pi-coding-agent": "~0.57.1",
"@mosaic/auth": "workspace:^",
"@mosaic/brain": "workspace:^",
"@mosaic/coord": "workspace:^",
"@mosaic/db": "workspace:^",
"@mosaic/types": "workspace:^",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-fastify": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"@nestjs/websockets": "^11.0.0",
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
"@opentelemetry/resources": "^2.6.0",
"@opentelemetry/sdk-metrics": "^2.6.0",
"@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@sinclair/typebox": "^0.34.48",
"better-auth": "^1.5.5",
"fastify": "^5.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"socket.io": "^4.8.0",
"uuid": "^11.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
}
}
@@ -0,0 +1,138 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RoutingService } from '../routing.service.js';
import type { ModelInfo } from '@mosaic/types';
const mockModels: ModelInfo[] = [
{
id: 'claude-3-haiku',
provider: 'anthropic',
name: 'Claude 3 Haiku',
reasoning: false,
contextWindow: 200_000,
maxTokens: 4096,
inputTypes: ['text', 'image'],
cost: { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 },
},
{
id: 'claude-3-sonnet',
provider: 'anthropic',
name: 'Claude 3 Sonnet',
reasoning: true,
contextWindow: 200_000,
maxTokens: 8192,
inputTypes: ['text', 'image'],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
},
{
id: 'llama3.2',
provider: 'ollama',
name: 'Llama 3.2',
reasoning: false,
contextWindow: 128_000,
maxTokens: 4096,
inputTypes: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
];
function createMockProviderService() {
return {
listAvailableModels: vi.fn().mockReturnValue(mockModels),
findModel: vi.fn(),
getDefaultModel: vi.fn(),
getRegistry: vi.fn(),
listProviders: vi.fn(),
registerCustomProvider: vi.fn(),
};
}
describe('RoutingService', () => {
let routingService: RoutingService;
let mockProviderService: ReturnType<typeof createMockProviderService>;
beforeEach(() => {
mockProviderService = createMockProviderService();
routingService = new RoutingService(mockProviderService as never);
});
it('returns a model when no criteria specified', () => {
const result = routingService.route();
expect(result).not.toBeNull();
expect(result!.provider).toBeDefined();
expect(result!.modelId).toBeDefined();
expect(result!.score).toBeGreaterThan(0);
});
it('returns null when no models available', () => {
mockProviderService.listAvailableModels.mockReturnValue([]);
const result = routingService.route();
expect(result).toBeNull();
});
it('selects preferred model when specified', () => {
const result = routingService.route({
preferredProvider: 'anthropic',
preferredModel: 'claude-3-sonnet',
});
expect(result).not.toBeNull();
expect(result!.provider).toBe('anthropic');
expect(result!.modelId).toBe('claude-3-sonnet');
expect(result!.score).toBe(100);
});
it('disqualifies models without reasoning when required', () => {
const result = routingService.route({ requireReasoning: true });
expect(result).not.toBeNull();
expect(result!.modelId).toBe('claude-3-sonnet');
});
it('disqualifies models without image input when required', () => {
const result = routingService.route({ requireImageInput: true });
expect(result).not.toBeNull();
// Llama doesn't support images, should be excluded
expect(result!.provider).toBe('anthropic');
});
it('respects minimum context window', () => {
const result = routingService.route({ minContextWindow: 150_000 });
expect(result).not.toBeNull();
// Only anthropic models have 200k context
expect(result!.provider).toBe('anthropic');
});
it('favors cheap models when costTier is cheap', () => {
const result = routingService.route({ costTier: 'cheap' });
expect(result).not.toBeNull();
// Ollama (free) and Haiku ($0.25/M) are cheap
expect(['ollama', 'anthropic'].includes(result!.provider)).toBe(true);
if (result!.provider === 'anthropic') {
expect(result!.modelId).toBe('claude-3-haiku');
}
});
it('ranks all models and returns sorted results', () => {
const ranked = routingService.rank({ taskType: 'coding' });
expect(ranked.length).toBeGreaterThan(0);
// Should be sorted by score descending
for (let i = 1; i < ranked.length; i++) {
expect(ranked[i]!.score).toBeLessThanOrEqual(ranked[i - 1]!.score);
}
});
it('gives reasoning bonus for coding tasks', () => {
const ranked = routingService.rank({ taskType: 'coding' });
const sonnet = ranked.find((r) => r.modelId === 'claude-3-sonnet');
const haiku = ranked.find((r) => r.modelId === 'claude-3-haiku');
expect(sonnet).toBeDefined();
expect(haiku).toBeDefined();
// Sonnet (reasoning) should score higher for coding than haiku (no reasoning)
expect(sonnet!.score).toBeGreaterThan(haiku!.score);
});
it('prefers specified provider', () => {
const ranked = routingService.rank({ preferredProvider: 'ollama' });
const ollamaModel = ranked.find((r) => r.provider === 'ollama');
expect(ollamaModel).toBeDefined();
expect(ollamaModel!.reasoning).toContain('preferred provider');
});
});
+16
View File
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { RoutingService } from './routing.service.js';
import { ProvidersController } from './providers.controller.js';
import { SessionsController } from './sessions.controller.js';
import { CoordModule } from '../coord/coord.module.js';
@Global()
@Module({
imports: [CoordModule],
providers: [ProviderService, RoutingService, AgentService],
controllers: [ProvidersController, SessionsController],
exports: [AgentService, ProviderService, RoutingService],
})
export class AgentModule {}
+249
View File
@@ -0,0 +1,249 @@
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
import {
createAgentSession,
SessionManager,
type AgentSession as PiAgentSession,
type AgentSessionEvent,
type ToolDefinition,
} from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { CoordService } from '../coord/coord.service.js';
import { ProviderService } from './provider.service.js';
import { createBrainTools } from './tools/brain-tools.js';
import { createCoordTools } from './tools/coord-tools.js';
import type { SessionInfoDto } from './session.dto.js';
export interface AgentSessionOptions {
provider?: string;
modelId?: string;
}
export interface AgentSession {
id: string;
provider: string;
modelId: string;
piSession: PiAgentSession;
listeners: Set<(event: AgentSessionEvent) => void>;
unsubscribe: () => void;
createdAt: number;
promptCount: number;
channels: Set<string>;
}
@Injectable()
export class AgentService implements OnModuleDestroy {
private readonly logger = new Logger(AgentService.name);
private readonly sessions = new Map<string, AgentSession>();
private readonly creating = new Map<string, Promise<AgentSession>>();
private readonly customTools: ToolDefinition[];
constructor(
@Inject(ProviderService) private readonly providerService: ProviderService,
@Inject(BRAIN) private readonly brain: Brain,
@Inject(CoordService) private readonly coordService: CoordService,
) {
this.customTools = [...createBrainTools(brain), ...createCoordTools(coordService)];
this.logger.log(`Registered ${this.customTools.length} custom tools`);
}
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
const existing = this.sessions.get(sessionId);
if (existing) return existing;
const inflight = this.creating.get(sessionId);
if (inflight) return inflight;
const promise = this.doCreateSession(sessionId, options).finally(() => {
this.creating.delete(sessionId);
});
this.creating.set(sessionId, promise);
return promise;
}
private async doCreateSession(
sessionId: string,
options?: AgentSessionOptions,
): Promise<AgentSession> {
const model = this.resolveModel(options);
const providerName = model?.provider ?? 'default';
const modelId = model?.id ?? 'default';
this.logger.log(
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId})`,
);
let piSession: PiAgentSession;
try {
const result = await createAgentSession({
sessionManager: SessionManager.inMemory(),
modelRegistry: this.providerService.getRegistry(),
model: model ?? undefined,
tools: [],
customTools: this.customTools,
});
piSession = result.session;
} catch (err) {
this.logger.error(
`Failed to create agent session for ${sessionId}`,
err instanceof Error ? err.stack : String(err),
);
throw new Error(`Agent session creation failed for ${sessionId}: ${String(err)}`);
}
const listeners = new Set<(event: AgentSessionEvent) => void>();
const unsubscribe = piSession.subscribe((event) => {
for (const listener of listeners) {
try {
listener(event);
} catch (err) {
this.logger.error(`Event listener error in session ${sessionId}`, err);
}
}
});
const session: AgentSession = {
id: sessionId,
provider: providerName,
modelId,
piSession,
listeners,
unsubscribe,
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
};
this.sessions.set(sessionId, session);
this.logger.log(`Agent session ${sessionId} ready (${providerName}/${modelId})`);
return session;
}
private resolveModel(options?: AgentSessionOptions) {
if (!options?.provider && !options?.modelId) {
return this.providerService.getDefaultModel() ?? null;
}
if (options.provider && options.modelId) {
const model = this.providerService.findModel(options.provider, options.modelId);
if (!model) {
throw new Error(`Model not found: ${options.provider}/${options.modelId}`);
}
return model;
}
if (options.modelId) {
const available = this.providerService.listAvailableModels();
const match = available.find((m) => m.id === options.modelId);
if (match) {
return this.providerService.findModel(match.provider, match.id) ?? null;
}
}
return this.providerService.getDefaultModel() ?? null;
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
}
listSessions(): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => ({
id: s.id,
provider: s.provider,
modelId: s.modelId,
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: now - s.createdAt,
}));
}
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
const s = this.sessions.get(sessionId);
if (!s) return undefined;
return {
id: s.id,
provider: s.provider,
modelId: s.modelId,
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: Date.now() - s.createdAt,
};
}
addChannel(sessionId: string, channel: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.add(channel);
}
}
removeChannel(sessionId: string, channel: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.delete(channel);
}
}
async prompt(sessionId: string, message: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
session.promptCount += 1;
try {
await session.piSession.prompt(message);
} catch (err) {
this.logger.error(
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
err instanceof Error ? err.stack : String(err),
);
throw err;
}
}
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
session.listeners.add(listener);
return () => session.listeners.delete(listener);
}
async destroySession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.logger.log(`Destroying agent session ${sessionId}`);
try {
session.unsubscribe();
} catch (err) {
this.logger.error(`Failed to unsubscribe session ${sessionId}`, String(err));
}
try {
session.piSession.dispose();
} catch (err) {
this.logger.error(`Failed to dispose piSession for ${sessionId}`, String(err));
}
session.listeners.clear();
session.channels.clear();
this.sessions.delete(sessionId);
}
async onModuleDestroy(): Promise<void> {
this.logger.log('Shutting down all agent sessions');
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
const results = await Promise.allSettled(stops);
for (const result of results) {
if (result.status === 'rejected') {
this.logger.error('Session shutdown failure', String(result.reason));
}
}
}
}
+139
View File
@@ -0,0 +1,139 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
import type { Model, Api } from '@mariozechner/pi-ai';
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
@Injectable()
export class ProviderService implements OnModuleInit {
private readonly logger = new Logger(ProviderService.name);
private registry!: ModelRegistry;
async onModuleInit(): Promise<void> {
const authStorage = AuthStorage.create();
this.registry = new ModelRegistry(authStorage);
this.registerOllamaProvider();
this.registerCustomProviders();
const available = this.registry.getAvailable();
this.logger.log(`Providers initialized: ${available.length} models available`);
}
getRegistry(): ModelRegistry {
return this.registry;
}
findModel(provider: string, modelId: string): Model<Api> | undefined {
return this.registry.find(provider, modelId);
}
getDefaultModel(): Model<Api> | undefined {
const available = this.registry.getAvailable();
return available[0];
}
listProviders(): ProviderInfo[] {
const allModels = this.registry.getAll();
const availableModels = this.registry.getAvailable();
const availableIds = new Set(availableModels.map((m) => `${m.provider}:${m.id}`));
const providerMap = new Map<string, ProviderInfo>();
for (const model of allModels) {
let info = providerMap.get(model.provider);
if (!info) {
info = {
id: model.provider,
name: model.provider,
available: false,
models: [],
};
providerMap.set(model.provider, info);
}
const isAvailable = availableIds.has(`${model.provider}:${model.id}`);
if (isAvailable) info.available = true;
info.models.push(this.toModelInfo(model));
}
return Array.from(providerMap.values());
}
listAvailableModels(): ModelInfo[] {
return this.registry.getAvailable().map((m) => this.toModelInfo(m));
}
registerCustomProvider(config: CustomProviderConfig): void {
this.registry.registerProvider(config.id, {
baseUrl: config.baseUrl,
apiKey: config.apiKey,
models: config.models.map((m) => ({
id: m.id,
name: m.name,
reasoning: m.reasoning ?? false,
input: ['text'] as ('text' | 'image')[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: m.contextWindow ?? 4096,
maxTokens: m.maxTokens ?? 4096,
})),
});
this.logger.log(`Registered custom provider: ${config.id} (${config.models.length} models)`);
}
private registerOllamaProvider(): void {
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
if (!ollamaUrl) return;
const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral';
const modelIds = modelsEnv
.split(',')
.map((m) => m.trim())
.filter(Boolean);
this.registerCustomProvider({
id: 'ollama',
name: 'Ollama',
baseUrl: `${ollamaUrl}/v1`,
models: modelIds.map((id) => ({
id,
name: id,
reasoning: false,
contextWindow: 8192,
maxTokens: 4096,
})),
});
this.logger.log(
`Ollama provider registered at ${ollamaUrl} with models: ${modelIds.join(', ')}`,
);
}
private registerCustomProviders(): void {
const customJson = process.env['MOSAIC_CUSTOM_PROVIDERS'];
if (!customJson) return;
try {
const configs = JSON.parse(customJson) as CustomProviderConfig[];
for (const config of configs) {
this.registerCustomProvider(config);
}
} catch (err) {
this.logger.error('Failed to parse MOSAIC_CUSTOM_PROVIDERS', String(err));
}
}
private toModelInfo(model: Model<Api>): ModelInfo {
return {
id: model.id,
provider: model.provider,
name: model.name,
reasoning: model.reasoning,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
inputTypes: model.input,
cost: model.cost,
};
}
}
@@ -0,0 +1,34 @@
import { Body, Controller, Get, Inject, Post, UseGuards } from '@nestjs/common';
import type { RoutingCriteria } from '@mosaic/types';
import { AuthGuard } from '../auth/auth.guard.js';
import { ProviderService } from './provider.service.js';
import { RoutingService } from './routing.service.js';
@Controller('api/providers')
@UseGuards(AuthGuard)
export class ProvidersController {
constructor(
@Inject(ProviderService) private readonly providerService: ProviderService,
@Inject(RoutingService) private readonly routingService: RoutingService,
) {}
@Get()
list() {
return this.providerService.listProviders();
}
@Get('models')
listModels() {
return this.providerService.listAvailableModels();
}
@Post('route')
route(@Body() criteria: RoutingCriteria) {
return this.routingService.route(criteria);
}
@Post('rank')
rank(@Body() criteria: RoutingCriteria) {
return this.routingService.rank(criteria);
}
}
+162
View File
@@ -0,0 +1,162 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ModelInfo } from '@mosaic/types';
import type { RoutingCriteria, RoutingResult, CostTier } from '@mosaic/types';
import { ProviderService } from './provider.service.js';
/** Per-million-token cost thresholds for tier classification */
const COST_TIER_THRESHOLDS: Record<CostTier, { maxInput: number }> = {
cheap: { maxInput: 1 },
standard: { maxInput: 10 },
premium: { maxInput: Infinity },
};
@Injectable()
export class RoutingService {
private readonly logger = new Logger(RoutingService.name);
constructor(@Inject(ProviderService) private readonly providerService: ProviderService) {}
/**
* Select the best available model for the given criteria.
* Returns null if no model matches the requirements.
*/
route(criteria: RoutingCriteria = {}): RoutingResult | null {
const available = this.providerService.listAvailableModels();
if (available.length === 0) {
this.logger.warn('No available models for routing');
return null;
}
// If a specific model is preferred, try it first
if (criteria.preferredProvider && criteria.preferredModel) {
const match = available.find(
(m) => m.provider === criteria.preferredProvider && m.id === criteria.preferredModel,
);
if (match) {
return {
provider: match.provider,
modelId: match.id,
modelName: match.name,
score: 100,
reasoning: 'Preferred model selected',
};
}
}
// Score and rank candidates
const scored = available
.map((model) => this.scoreModel(model, criteria))
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score);
if (scored.length === 0) {
this.logger.warn('No models matched routing criteria', criteria);
return null;
}
const best = scored[0] as RoutingResult;
this.logger.debug(
`Routed to ${best.provider}/${best.modelId} (score=${best.score}): ${best.reasoning}`,
);
return best;
}
/**
* List all available models ranked by suitability for the given criteria.
*/
rank(criteria: RoutingCriteria = {}): RoutingResult[] {
const available = this.providerService.listAvailableModels();
return available
.map((model) => this.scoreModel(model, criteria))
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score);
}
private scoreModel(model: ModelInfo, criteria: RoutingCriteria): RoutingResult {
let score = 50; // Base score
const reasons: string[] = [];
// Hard requirements — disqualify if not met
if (criteria.requireReasoning && !model.reasoning) {
return this.disqualified(model, 'reasoning required but not supported');
}
if (criteria.requireImageInput && !model.inputTypes.includes('image')) {
return this.disqualified(model, 'image input required but not supported');
}
if (criteria.minContextWindow && model.contextWindow < criteria.minContextWindow) {
return this.disqualified(
model,
`context window ${model.contextWindow} < required ${criteria.minContextWindow}`,
);
}
// Cost tier matching
if (criteria.costTier) {
const tier = this.classifyTier(model);
if (tier === criteria.costTier) {
score += 20;
reasons.push(`cost tier match (${tier})`);
} else if (
(criteria.costTier === 'cheap' && tier === 'standard') ||
(criteria.costTier === 'standard' && tier === 'premium')
) {
score += 5;
reasons.push(`adjacent cost tier (wanted ${criteria.costTier}, got ${tier})`);
}
}
// Prefer cheaper models when no cost tier specified
if (!criteria.costTier) {
const costPerMillion = model.cost.input;
if (costPerMillion <= 1) score += 10;
else if (costPerMillion <= 5) score += 5;
}
// Provider preference
if (criteria.preferredProvider && model.provider === criteria.preferredProvider) {
score += 15;
reasons.push('preferred provider');
}
// Reasoning bonus for complex tasks
if (model.reasoning) {
if (criteria.taskType === 'coding' || criteria.taskType === 'analysis') {
score += 10;
reasons.push('reasoning model for complex task');
}
}
// Large context bonus for analysis tasks
if (criteria.taskType === 'analysis' && model.contextWindow >= 128_000) {
score += 5;
reasons.push('large context window');
}
return {
provider: model.provider,
modelId: model.id,
modelName: model.name,
score,
reasoning: reasons.length > 0 ? reasons.join('; ') : 'base score',
};
}
private classifyTier(model: ModelInfo): CostTier {
const cost = model.cost.input;
if (cost <= COST_TIER_THRESHOLDS.cheap.maxInput) return 'cheap';
if (cost <= COST_TIER_THRESHOLDS.standard.maxInput) return 'standard';
return 'premium';
}
private disqualified(model: ModelInfo, reason: string): RoutingResult {
return {
provider: model.provider,
modelId: model.id,
modelName: model.name,
score: 0,
reasoning: `disqualified: ${reason}`,
};
}
}
+14
View File
@@ -0,0 +1,14 @@
export interface SessionInfoDto {
id: string;
provider: string;
modelId: string;
createdAt: string;
promptCount: number;
channels: string[];
durationMs: number;
}
export interface SessionListDto {
sessions: SessionInfoDto[];
total: number;
}
@@ -0,0 +1,40 @@
import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { AgentService } from './agent.service.js';
@Controller('api/sessions')
@UseGuards(AuthGuard)
export class SessionsController {
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Get()
list() {
const sessions = this.agentService.listSessions();
return { sessions, total: sessions.length };
}
@Get(':id')
findOne(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
if (!info) throw new NotFoundException('Session not found');
return info;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async destroy(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
if (!info) throw new NotFoundException('Session not found');
await this.agentService.destroySession(id);
}
}
+184
View File
@@ -0,0 +1,184 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaic/brain';
export function createBrainTools(brain: Brain): ToolDefinition[] {
const listProjects: ToolDefinition = {
name: 'brain_list_projects',
label: 'List Projects',
description: 'List all projects in the brain.',
parameters: Type.Object({}),
async execute() {
const projects = await brain.projects.findAll();
return {
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
details: undefined,
};
},
};
const getProject: ToolDefinition = {
name: 'brain_get_project',
label: 'Get Project',
description: 'Get a project by ID.',
parameters: Type.Object({
id: Type.String({ description: 'Project ID (UUID)' }),
}),
async execute(_toolCallId, params) {
const { id } = params as { id: string };
const project = await brain.projects.findById(id);
return {
content: [
{
type: 'text' as const,
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
},
],
details: undefined,
};
},
};
const listTasks: ToolDefinition = {
name: 'brain_list_tasks',
label: 'List Tasks',
description: 'List tasks, optionally filtered by project, mission, or status.',
parameters: Type.Object({
projectId: Type.Optional(Type.String({ description: 'Filter by project ID' })),
missionId: Type.Optional(Type.String({ description: 'Filter by mission ID' })),
status: Type.Optional(Type.String({ description: 'Filter by status' })),
}),
async execute(_toolCallId, params) {
const p = params as { projectId?: string; missionId?: string; status?: string };
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
let tasks;
if (p.projectId) tasks = await brain.tasks.findByProject(p.projectId);
else if (p.missionId) tasks = await brain.tasks.findByMission(p.missionId);
else if (p.status) tasks = await brain.tasks.findByStatus(p.status as TaskStatus);
else tasks = await brain.tasks.findAll();
return {
content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }],
details: undefined,
};
},
};
const createTask: ToolDefinition = {
name: 'brain_create_task',
label: 'Create Task',
description: 'Create a new task in the brain.',
parameters: Type.Object({
title: Type.String({ description: 'Task title' }),
description: Type.Optional(Type.String({ description: 'Task description' })),
projectId: Type.Optional(Type.String({ description: 'Project ID' })),
missionId: Type.Optional(Type.String({ description: 'Mission ID' })),
priority: Type.Optional(
Type.String({ description: 'Priority: low, medium, high, critical' }),
),
}),
async execute(_toolCallId, params) {
const p = params as {
title: string;
description?: string;
projectId?: string;
missionId?: string;
priority?: string;
};
type Priority = 'low' | 'medium' | 'high' | 'critical';
const task = await brain.tasks.create({
...p,
priority: p.priority as Priority | undefined,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(task, null, 2) }],
details: undefined,
};
},
};
const updateTask: ToolDefinition = {
name: 'brain_update_task',
label: 'Update Task',
description: 'Update an existing task.',
parameters: Type.Object({
id: Type.String({ description: 'Task ID' }),
title: Type.Optional(Type.String()),
description: Type.Optional(Type.String()),
status: Type.Optional(
Type.String({ description: 'not-started, in-progress, blocked, done, cancelled' }),
),
priority: Type.Optional(Type.String()),
}),
async execute(_toolCallId, params) {
const { id, ...updates } = params as {
id: string;
title?: string;
description?: string;
status?: string;
priority?: string;
};
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
type Priority = 'low' | 'medium' | 'high' | 'critical';
const task = await brain.tasks.update(id, {
...updates,
status: updates.status as TaskStatus | undefined,
priority: updates.priority as Priority | undefined,
});
return {
content: [
{
type: 'text' as const,
text: task ? JSON.stringify(task, null, 2) : `Task not found: ${id}`,
},
],
details: undefined,
};
},
};
const listMissions: ToolDefinition = {
name: 'brain_list_missions',
label: 'List Missions',
description: 'List all missions, optionally filtered by project.',
parameters: Type.Object({
projectId: Type.Optional(Type.String({ description: 'Filter by project ID' })),
}),
async execute(_toolCallId, params) {
const p = params as { projectId?: string };
const missions = p.projectId
? await brain.missions.findByProject(p.projectId)
: await brain.missions.findAll();
return {
content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }],
details: undefined,
};
},
};
const listConversations: ToolDefinition = {
name: 'brain_list_conversations',
label: 'List Conversations',
description: 'List conversations for a user.',
parameters: Type.Object({
userId: Type.String({ description: 'User ID' }),
}),
async execute(_toolCallId, params) {
const { userId } = params as { userId: string };
const conversations = await brain.conversations.findAll(userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
details: undefined,
};
},
};
return [
listProjects,
getProject,
listTasks,
createTask,
updateTask,
listMissions,
listConversations,
];
}
@@ -0,0 +1,81 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { CoordService } from '../../coord/coord.service.js';
export function createCoordTools(coordService: CoordService): ToolDefinition[] {
const getMissionStatus: ToolDefinition = {
name: 'coord_mission_status',
label: 'Mission Status',
description:
'Get the current orchestration mission status including milestones, tasks, and active session.',
parameters: Type.Object({
projectPath: Type.Optional(
Type.String({ description: 'Project path. Defaults to gateway working directory.' }),
),
}),
async execute(_toolCallId, params) {
const { projectPath } = params as { projectPath?: string };
const resolvedPath = projectPath ?? process.cwd();
const status = await coordService.getMissionStatus(resolvedPath);
return {
content: [
{
type: 'text' as const,
text: status ? JSON.stringify(status, null, 2) : 'No active coord mission found.',
},
],
details: undefined,
};
},
};
const listCoordTasks: ToolDefinition = {
name: 'coord_list_tasks',
label: 'List Coord Tasks',
description: 'List all tasks from the orchestration TASKS.md file.',
parameters: Type.Object({
projectPath: Type.Optional(
Type.String({ description: 'Project path. Defaults to gateway working directory.' }),
),
}),
async execute(_toolCallId, params) {
const { projectPath } = params as { projectPath?: string };
const resolvedPath = projectPath ?? process.cwd();
const tasks = await coordService.listTasks(resolvedPath);
return {
content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }],
details: undefined,
};
},
};
const getCoordTaskDetail: ToolDefinition = {
name: 'coord_task_detail',
label: 'Coord Task Detail',
description: 'Get detailed status for a specific orchestration task.',
parameters: Type.Object({
taskId: Type.String({ description: 'Task ID (e.g. P2-005)' }),
projectPath: Type.Optional(
Type.String({ description: 'Project path. Defaults to gateway working directory.' }),
),
}),
async execute(_toolCallId, params) {
const { taskId, projectPath } = params as { taskId: string; projectPath?: string };
const resolvedPath = projectPath ?? process.cwd();
const detail = await coordService.getTaskStatus(resolvedPath, taskId);
return {
content: [
{
type: 'text' as const,
text: detail
? JSON.stringify(detail, null, 2)
: `Task ${taskId} not found in coord mission.`,
},
],
details: undefined,
};
},
};
return [getMissionStatus, listCoordTasks, getCoordTaskDetail];
}
+2
View File
@@ -0,0 +1,2 @@
export { createBrainTools } from './brain-tools.js';
export { createCoordTools } from './coord-tools.js';
+29
View File
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health/health.controller.js';
import { DatabaseModule } from './database/database.module.js';
import { AuthModule } from './auth/auth.module.js';
import { BrainModule } from './brain/brain.module.js';
import { AgentModule } from './agent/agent.module.js';
import { ChatModule } from './chat/chat.module.js';
import { ConversationsModule } from './conversations/conversations.module.js';
import { ProjectsModule } from './projects/projects.module.js';
import { MissionsModule } from './missions/missions.module.js';
import { TasksModule } from './tasks/tasks.module.js';
import { CoordModule } from './coord/coord.module.js';
@Module({
imports: [
DatabaseModule,
AuthModule,
BrainModule,
AgentModule,
ChatModule,
ConversationsModule,
ProjectsModule,
MissionsModule,
TasksModule,
CoordModule,
],
controllers: [HealthController],
})
export class AppModule {}
+46
View File
@@ -0,0 +1,46 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { toNodeHandler } from 'better-auth/node';
import type { Auth } from '@mosaic/auth';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { AUTH } from './auth.tokens.js';
export function mountAuthHandler(app: NestFastifyApplication): void {
const auth = app.get<Auth>(AUTH);
const nodeHandler = toNodeHandler(auth);
const fastify = app.getHttpAdapter().getInstance();
// Use Fastify's addHook to intercept auth requests at the raw HTTP level,
// before Fastify's body parser runs. This avoids conflicts with NestJS's
// custom content-type parser.
fastify.addHook(
'onRequest',
(
req: { raw: IncomingMessage; url: string },
reply: { raw: ServerResponse; hijack: () => void },
done: () => void,
) => {
if (!req.url.startsWith('/api/auth/')) {
done();
return;
}
reply.hijack();
nodeHandler(req.raw as IncomingMessage, reply.raw as ServerResponse)
.then(() => {
if (!reply.raw.writableEnded) {
reply.raw.end();
}
})
.catch((err: unknown) => {
if (!reply.raw.headersSent) {
reply.raw.writeHead(500, { 'Content-Type': 'application/json' });
}
if (!reply.raw.writableEnded) {
reply.raw.end(JSON.stringify({ error: 'Internal auth error' }));
}
console.error('[AUTH] Handler error:', err);
});
},
);
}
+32
View File
@@ -0,0 +1,32 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaic/auth';
import type { FastifyRequest } from 'fastify';
import { AUTH } from './auth.tokens.js';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(@Inject(AUTH) private readonly auth: Auth) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const headers = fromNodeHeaders(request.raw.headers);
const result = await this.auth.api.getSession({ headers });
if (!result) {
throw new UnauthorizedException('Invalid or expired session');
}
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
return true;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Global, Module } from '@nestjs/common';
import { createAuth, type Auth } from '@mosaic/auth';
import type { Db } from '@mosaic/db';
import { DB } from '../database/database.module.js';
import { AUTH } from './auth.tokens.js';
@Global()
@Module({
providers: [
{
provide: AUTH,
useFactory: (db: Db): Auth =>
createAuth({
db,
baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000',
secret: process.env['BETTER_AUTH_SECRET'],
}),
inject: [DB],
},
],
exports: [AUTH],
})
export class AuthModule {}
+1
View File
@@ -0,0 +1 @@
export const AUTH = 'AUTH';
@@ -0,0 +1,7 @@
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<FastifyRequest & { user?: unknown }>();
return request.user;
});
+18
View File
@@ -0,0 +1,18 @@
import { Global, Module } from '@nestjs/common';
import { createBrain, type Brain } from '@mosaic/brain';
import type { Db } from '@mosaic/db';
import { DB } from '../database/database.module.js';
import { BRAIN } from './brain.tokens.js';
@Global()
@Module({
providers: [
{
provide: BRAIN,
useFactory: (db: Db): Brain => createBrain(db),
inject: [DB],
},
],
exports: [BRAIN],
})
export class BrainModule {}
+1
View File
@@ -0,0 +1 @@
export const BRAIN = 'BRAIN';
+75
View File
@@ -0,0 +1,75 @@
import { Controller, Post, Body, Logger, HttpException, HttpStatus, Inject } from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { AgentService } from '../agent/agent.service.js';
import { v4 as uuid } from 'uuid';
interface ChatRequest {
conversationId?: string;
content: string;
}
interface ChatResponse {
conversationId: string;
text: string;
}
@Controller('api/chat')
export class ChatController {
private readonly logger = new Logger(ChatController.name);
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Post()
async chat(@Body() body: ChatRequest): Promise<ChatResponse> {
const conversationId = body.conversationId ?? uuid();
try {
let agentSession = this.agentService.getSession(conversationId);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId);
}
} catch (err) {
this.logger.error(
`Session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
}
let responseText = '';
const done = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
reject(new Error('Agent response timed out'));
}, 120_000);
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
clearTimeout(timer);
cleanup();
resolve();
}
});
});
try {
await this.agentService.prompt(conversationId, body.content);
await done;
} catch (err) {
if (err instanceof HttpException) throw err;
const message = err instanceof Error ? err.message : String(err);
if (message.includes('timed out')) {
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
}
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
return { conversationId, text: responseText };
}
}
+173
View File
@@ -0,0 +1,173 @@
import { Inject, Logger } from '@nestjs/common';
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
type OnGatewayInit,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { AgentService } from '../agent/agent.service.js';
import { v4 as uuid } from 'uuid';
interface ChatMessage {
conversationId?: string;
content: string;
provider?: string;
modelId?: string;
}
@WebSocketGateway({
cors: { origin: '*' },
namespace: '/chat',
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(ChatGateway.name);
private readonly clientSessions = new Map<
string,
{ conversationId: string; cleanup: () => void }
>();
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
afterInit(): void {
this.logger.log('Chat WebSocket gateway initialized');
}
handleConnection(client: Socket): void {
this.logger.log(`Client connected: ${client.id}`);
}
handleDisconnect(client: Socket): void {
this.logger.log(`Client disconnected: ${client.id}`);
const session = this.clientSessions.get(client.id);
if (session) {
session.cleanup();
this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`);
this.clientSessions.delete(client.id);
}
}
@SubscribeMessage('message')
async handleMessage(
@ConnectedSocket() client: Socket,
@MessageBody() data: ChatMessage,
): Promise<void> {
const conversationId = data.conversationId ?? uuid();
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
// Ensure agent session exists for this conversation
try {
let agentSession = this.agentService.getSession(conversationId);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId, {
provider: data.provider,
modelId: data.modelId,
});
}
} catch (err) {
this.logger.error(
`Session creation failed for client=${client.id}, conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
client.emit('error', {
conversationId,
error: 'Failed to start agent session. Please try again.',
});
return;
}
// Always clean up previous listener to prevent leak
const existing = this.clientSessions.get(client.id);
if (existing) {
existing.cleanup();
}
// Subscribe to agent events and relay to client
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
this.relayEvent(client, conversationId, event);
});
this.clientSessions.set(client.id, { conversationId, cleanup });
// Track channel connection
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
// Send acknowledgment
client.emit('message:ack', { conversationId, messageId: uuid() });
// Dispatch to agent
try {
await this.agentService.prompt(conversationId, data.content);
} catch (err) {
this.logger.error(
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
client.emit('error', {
conversationId,
error: 'The agent failed to process your message. Please try again.',
});
}
}
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
if (!client.connected) {
this.logger.warn(
`Dropping event ${event.type} for disconnected client=${client.id}, conversation=${conversationId}`,
);
return;
}
switch (event.type) {
case 'agent_start':
client.emit('agent:start', { conversationId });
break;
case 'agent_end':
client.emit('agent:end', { conversationId });
break;
case 'message_update': {
const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type === 'text_delta') {
client.emit('agent:text', {
conversationId,
text: assistantEvent.delta,
});
} else if (assistantEvent.type === 'thinking_delta') {
client.emit('agent:thinking', {
conversationId,
text: assistantEvent.delta,
});
}
break;
}
case 'tool_execution_start':
client.emit('agent:tool:start', {
conversationId,
toolCallId: event.toolCallId,
toolName: event.toolName,
});
break;
case 'tool_execution_end':
client.emit('agent:tool:end', {
conversationId,
toolCallId: event.toolCallId,
toolName: event.toolName,
isError: event.isError,
});
break;
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway.js';
import { ChatController } from './chat.controller.js';
@Module({
controllers: [ChatController],
providers: [ChatGateway],
})
export class ChatModule {}
@@ -0,0 +1,83 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import type {
CreateConversationDto,
UpdateConversationDto,
SendMessageDto,
} from './conversations.dto.js';
@Controller('api/conversations')
@UseGuards(AuthGuard)
export class ConversationsController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list(@CurrentUser() user: { id: string }) {
return this.brain.conversations.findAll(user.id);
}
@Get(':id')
async findOne(@Param('id') id: string) {
const conversation = await this.brain.conversations.findById(id);
if (!conversation) throw new NotFoundException('Conversation not found');
return conversation;
}
@Post()
async create(@CurrentUser() user: { id: string }, @Body() dto: CreateConversationDto) {
return this.brain.conversations.create({
userId: user.id,
title: dto.title,
projectId: dto.projectId,
});
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateConversationDto) {
const conversation = await this.brain.conversations.update(id, dto);
if (!conversation) throw new NotFoundException('Conversation not found');
return conversation;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
const deleted = await this.brain.conversations.remove(id);
if (!deleted) throw new NotFoundException('Conversation not found');
}
@Get(':id/messages')
async listMessages(@Param('id') id: string) {
const conversation = await this.brain.conversations.findById(id);
if (!conversation) throw new NotFoundException('Conversation not found');
return this.brain.conversations.findMessages(id);
}
@Post(':id/messages')
async addMessage(@Param('id') id: string, @Body() dto: SendMessageDto) {
const conversation = await this.brain.conversations.findById(id);
if (!conversation) throw new NotFoundException('Conversation not found');
return this.brain.conversations.addMessage({
conversationId: id,
role: dto.role,
content: dto.content,
metadata: dto.metadata,
});
}
}
@@ -0,0 +1,15 @@
export interface CreateConversationDto {
title?: string;
projectId?: string;
}
export interface UpdateConversationDto {
title?: string;
projectId?: string | null;
}
export interface SendMessageDto {
role: 'user' | 'assistant' | 'system';
content: string;
metadata?: Record<string, unknown>;
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ConversationsController } from './conversations.controller.js';
@Module({
controllers: [ConversationsController],
})
export class ConversationsModule {}
@@ -0,0 +1,73 @@
import {
BadRequestException,
Controller,
Get,
Inject,
NotFoundException,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import fs from 'node:fs';
import path from 'node:path';
import { AuthGuard } from '../auth/auth.guard.js';
import { CoordService } from './coord.service.js';
/** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */
function findMonorepoRoot(start: string): string {
let dir = start;
for (let i = 0; i < 5; i++) {
try {
fs.accessSync(path.join(dir, 'pnpm-workspace.yaml'));
return dir;
} catch {
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
return start;
}
/** Only paths under these roots are allowed for coord queries. */
const WORKSPACE_ROOT = process.env['MOSAIC_WORKSPACE_ROOT'] ?? findMonorepoRoot(process.cwd());
const ALLOWED_ROOTS = [process.cwd(), WORKSPACE_ROOT];
function resolveAndValidatePath(raw: string | undefined): string {
const resolved = path.resolve(raw ?? process.cwd());
const isAllowed = ALLOWED_ROOTS.some(
(root) => resolved === root || resolved.startsWith(`${root}/`),
);
if (!isAllowed) {
throw new BadRequestException('projectPath is outside the allowed workspace');
}
return resolved;
}
@Controller('api/coord')
@UseGuards(AuthGuard)
export class CoordController {
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
@Get('status')
async missionStatus(@Query('projectPath') projectPath?: string) {
const resolvedPath = resolveAndValidatePath(projectPath);
const status = await this.coordService.getMissionStatus(resolvedPath);
if (!status) throw new NotFoundException('No active coord mission found');
return status;
}
@Get('tasks')
async listTasks(@Query('projectPath') projectPath?: string) {
const resolvedPath = resolveAndValidatePath(projectPath);
return this.coordService.listTasks(resolvedPath);
}
@Get('tasks/:taskId')
async taskStatus(@Param('taskId') taskId: string, @Query('projectPath') projectPath?: string) {
const resolvedPath = resolveAndValidatePath(projectPath);
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
return detail;
}
}
+49
View File
@@ -0,0 +1,49 @@
export interface CoordMissionStatusDto {
mission: {
id: string;
name: string;
status: string;
projectPath: string;
};
milestones: {
total: number;
completed: number;
current?: {
id: string;
name: string;
status: string;
};
};
tasks: {
total: number;
done: number;
inProgress: number;
pending: number;
blocked: number;
cancelled: number;
};
nextTaskId?: string;
activeSession?: {
sessionId: string;
runtime: string;
startedAt: string;
};
}
export interface CoordTaskDetailDto {
missionId: string;
task: {
id: string;
title: string;
status: string;
milestone?: string;
pr?: string;
notes?: string;
};
isNextTask: boolean;
activeSession?: {
sessionId: string;
runtime: string;
startedAt: string;
};
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CoordService } from './coord.service.js';
import { CoordController } from './coord.controller.js';
@Module({
providers: [CoordService],
controllers: [CoordController],
exports: [CoordService],
})
export class CoordModule {}
+73
View File
@@ -0,0 +1,73 @@
import { Injectable, Logger } from '@nestjs/common';
import {
loadMission,
getMissionStatus,
getTaskStatus,
parseTasksFile,
type Mission,
type MissionStatusSummary,
type MissionTask,
type TaskDetail,
} from '@mosaic/coord';
import { promises as fs } from 'node:fs';
import path from 'node:path';
@Injectable()
export class CoordService {
private readonly logger = new Logger(CoordService.name);
async loadMission(projectPath: string): Promise<Mission | null> {
try {
return await loadMission(projectPath);
} catch (err) {
this.logger.debug(
`No coord mission at ${projectPath}: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
}
async getMissionStatus(projectPath: string): Promise<MissionStatusSummary | null> {
const mission = await this.loadMission(projectPath);
if (!mission) return null;
try {
return await getMissionStatus(mission);
} catch (err) {
this.logger.error(
`Failed to get mission status: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
}
async getTaskStatus(projectPath: string, taskId: string): Promise<TaskDetail | null> {
const mission = await this.loadMission(projectPath);
if (!mission) return null;
try {
return await getTaskStatus(mission, taskId);
} catch (err) {
this.logger.error(
`Failed to get task status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
}
async listTasks(projectPath: string): Promise<MissionTask[]> {
const mission = await this.loadMission(projectPath);
if (!mission) return [];
const tasksFile = path.isAbsolute(mission.tasksFile)
? mission.tasksFile
: path.join(mission.projectPath, mission.tasksFile);
try {
const content = await fs.readFile(tasksFile, 'utf8');
return parseTasksFile(content);
} catch {
return [];
}
}
}
@@ -0,0 +1,28 @@
import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createDb, type Db, type DbHandle } from '@mosaic/db';
export const DB_HANDLE = 'DB_HANDLE';
export const DB = 'DB';
@Global()
@Module({
providers: [
{
provide: DB_HANDLE,
useFactory: (): DbHandle => createDb(),
},
{
provide: DB,
useFactory: (handle: DbHandle): Db => handle.db,
inject: [DB_HANDLE],
},
],
exports: [DB],
})
export class DatabaseModule implements OnApplicationShutdown {
constructor(@Inject(DB_HANDLE) private readonly handle: DbHandle) {}
async onApplicationShutdown(): Promise<void> {
await this.handle.close();
}
}
@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check(): { status: string } {
return { status: 'ok' };
}
}
+24
View File
@@ -0,0 +1,24 @@
import './tracing.js';
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';
import { mountAuthHandler } from './auth/auth.controller.js';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
mountAuthHandler(app);
const port = process.env['GATEWAY_PORT'] ?? 4000;
await app.listen(port as number, '0.0.0.0');
logger.log(`Gateway listening on port ${port}`);
}
bootstrap().catch((err: unknown) => {
const logger = new Logger('Bootstrap');
logger.error('Fatal startup error', err instanceof Error ? err.stack : String(err));
process.exit(1);
});
@@ -0,0 +1,60 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import type { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
@Controller('api/missions')
@UseGuards(AuthGuard)
export class MissionsController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list() {
return this.brain.missions.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string) {
const mission = await this.brain.missions.findById(id);
if (!mission) throw new NotFoundException('Mission not found');
return mission;
}
@Post()
async create(@Body() dto: CreateMissionDto) {
return this.brain.missions.create({
name: dto.name,
description: dto.description,
projectId: dto.projectId,
status: dto.status,
});
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateMissionDto) {
const mission = await this.brain.missions.update(id, dto);
if (!mission) throw new NotFoundException('Mission not found');
return mission;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
const deleted = await this.brain.missions.remove(id);
if (!deleted) throw new NotFoundException('Mission not found');
}
}
+14
View File
@@ -0,0 +1,14 @@
export interface CreateMissionDto {
name: string;
description?: string;
projectId?: string;
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
}
export interface UpdateMissionDto {
name?: string;
description?: string | null;
projectId?: string | null;
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
metadata?: Record<string, unknown> | null;
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { MissionsController } from './missions.controller.js';
@Module({
controllers: [MissionsController],
})
export class MissionsModule {}
@@ -0,0 +1,61 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import type { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
@Controller('api/projects')
@UseGuards(AuthGuard)
export class ProjectsController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list() {
return this.brain.projects.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string) {
const project = await this.brain.projects.findById(id);
if (!project) throw new NotFoundException('Project not found');
return project;
}
@Post()
async create(@CurrentUser() user: { id: string }, @Body() dto: CreateProjectDto) {
return this.brain.projects.create({
name: dto.name,
description: dto.description,
status: dto.status,
ownerId: user.id,
});
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
const project = await this.brain.projects.update(id, dto);
if (!project) throw new NotFoundException('Project not found');
return project;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
const deleted = await this.brain.projects.remove(id);
if (!deleted) throw new NotFoundException('Project not found');
}
}
+12
View File
@@ -0,0 +1,12 @@
export interface CreateProjectDto {
name: string;
description?: string;
status?: 'active' | 'paused' | 'completed' | 'archived';
}
export interface UpdateProjectDto {
name?: string;
description?: string | null;
status?: 'active' | 'paused' | 'completed' | 'archived';
metadata?: Record<string, unknown> | null;
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ProjectsController } from './projects.controller.js';
@Module({
controllers: [ProjectsController],
})
export class ProjectsModule {}
@@ -0,0 +1,79 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import type { CreateTaskDto, UpdateTaskDto } from './tasks.dto.js';
@Controller('api/tasks')
@UseGuards(AuthGuard)
export class TasksController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list(
@Query('projectId') projectId?: string,
@Query('missionId') missionId?: string,
@Query('status') status?: string,
) {
if (projectId) return this.brain.tasks.findByProject(projectId);
if (missionId) return this.brain.tasks.findByMission(missionId);
if (status)
return this.brain.tasks.findByStatus(
status as Parameters<typeof this.brain.tasks.findByStatus>[0],
);
return this.brain.tasks.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string) {
const task = await this.brain.tasks.findById(id);
if (!task) throw new NotFoundException('Task not found');
return task;
}
@Post()
async create(@Body() dto: CreateTaskDto) {
return this.brain.tasks.create({
title: dto.title,
description: dto.description,
status: dto.status,
priority: dto.priority,
projectId: dto.projectId,
missionId: dto.missionId,
assignee: dto.assignee,
tags: dto.tags,
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
});
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateTaskDto) {
const task = await this.brain.tasks.update(id, {
...dto,
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
});
if (!task) throw new NotFoundException('Task not found');
return task;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
const deleted = await this.brain.tasks.remove(id);
if (!deleted) throw new NotFoundException('Task not found');
}
}
+24
View File
@@ -0,0 +1,24 @@
export interface CreateTaskDto {
title: string;
description?: string;
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
priority?: 'critical' | 'high' | 'medium' | 'low';
projectId?: string;
missionId?: string;
assignee?: string;
tags?: string[];
dueDate?: string;
}
export interface UpdateTaskDto {
title?: string;
description?: string | null;
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
priority?: 'critical' | 'high' | 'medium' | 'low';
projectId?: string | null;
missionId?: string | null;
assignee?: string | null;
tags?: string[] | null;
dueDate?: string | null;
metadata?: Record<string, unknown> | null;
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller.js';
@Module({
controllers: [TasksController],
})
export class TasksModule {}
+42
View File
@@ -0,0 +1,42 @@
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
const otlpEndpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? 'http://localhost:4318';
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env['OTEL_SERVICE_NAME'] ?? 'mosaic-gateway',
[ATTR_SERVICE_VERSION]: '0.0.0',
}),
traceExporter: new OTLPTraceExporter({
url: `${otlpEndpoint}/v1/traces`,
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${otlpEndpoint}/v1/metrics`,
}),
exportIntervalMillis: 15_000,
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk
.shutdown()
.then(() => process.exit(0))
.catch(() => process.exit(1));
});
export { sdk };
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@mosaic/design-tokens'],
};
export default nextConfig;
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@mosaic/web",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"start": "next start"
},
"dependencies": {
"@mosaic/design-tokens": "workspace:^",
"better-auth": "^1.5.5",
"clsx": "^2.1.0",
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"socket.io-client": "^4.8.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { GuestGuard } from '@/components/guest-guard';
export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<GuestGuard>
<div className="flex min-h-screen items-center justify-center bg-surface-bg">
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
{children}
</div>
</div>
</GuestGuard>
);
}
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signIn } from '@/lib/auth-client';
export default function LoginPage(): React.ReactElement {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setLoading(true);
const form = new FormData(e.currentTarget);
const email = form.get('email') as string;
const password = form.get('password') as string;
const result = await signIn.email({ email, password });
if (result.error) {
setError(result.error.message ?? 'Sign in failed');
setLoading(false);
return;
}
router.push('/chat');
}
return (
<div>
<h1 className="text-2xl font-semibold">Sign in</h1>
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
{error && (
<div
role="alert"
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
{error}
</div>
)}
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="[email protected]"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className="mt-4 text-center text-sm text-text-muted">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-blue-400 hover:text-blue-300">
Sign up
</Link>
</p>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signUp } from '@/lib/auth-client';
export default function RegisterPage(): React.ReactElement {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setLoading(true);
const form = new FormData(e.currentTarget);
const name = form.get('name') as string;
const email = form.get('email') as string;
const password = form.get('password') as string;
const result = await signUp.email({ name, email, password });
if (result.error) {
setError(result.error.message ?? 'Registration failed');
setLoading(false);
return;
}
router.push('/chat');
}
return (
<div>
<h1 className="text-2xl font-semibold">Create account</h1>
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
{error && (
<div
role="alert"
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
{error}
</div>
)}
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
Name
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="[email protected]"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
<p className="mt-4 text-center text-sm text-text-muted">
Already have an account?{' '}
<Link href="/login" className="text-blue-400 hover:text-blue-300">
Sign in
</Link>
</p>
</div>
);
}
@@ -0,0 +1,99 @@
'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
interface SessionInfo {
id: string;
provider: string;
modelId: string;
createdAt: string;
promptCount: number;
channels: string[];
durationMs: number;
}
interface SessionsResponse {
sessions: SessionInfo[];
total: number;
}
export default function AdminPage(): React.ReactElement {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api<SessionsResponse>('/api/sessions')
.then((res) => setSessions(res.sessions))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return (
<div className="mx-auto max-w-4xl space-y-8">
<h1 className="text-2xl font-semibold">Admin</h1>
{/* User Management placeholder */}
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">User Management</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
<p className="text-sm text-text-muted">
User management will be available when the admin API is implemented
</p>
</div>
</section>
{/* Active Agent Sessions */}
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Active Agent Sessions</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading sessions...</p>
) : sessions.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<p className="text-sm text-text-muted">No active sessions</p>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-surface-border">
<table className="w-full">
<thead>
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Session ID</th>
<th className="px-4 py-2 font-medium">Provider</th>
<th className="px-4 py-2 font-medium">Model</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Prompts</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Duration</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.id} className="border-b border-surface-border last:border-b-0">
<td className="max-w-[200px] truncate px-4 py-2 font-mono text-xs text-text-primary">
{s.id}
</td>
<td className="px-4 py-2 text-xs text-text-muted">{s.provider}</td>
<td className="px-4 py-2 text-xs text-text-muted">{s.modelId}</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{s.promptCount}
</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{formatDuration(s.durationMs)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
+179
View File
@@ -0,0 +1,179 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api';
import { getSocket } from '@/lib/socket';
import type { Conversation, Message } from '@/lib/types';
import { ConversationList } from '@/components/chat/conversation-list';
import { MessageBubble } from '@/components/chat/message-bubble';
import { ChatInput } from '@/components/chat/chat-input';
import { StreamingMessage } from '@/components/chat/streaming-message';
export default function ChatPage(): React.ReactElement {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Load conversations on mount
useEffect(() => {
api<Conversation[]>('/api/conversations')
.then(setConversations)
.catch(() => {});
}, []);
// Load messages when active conversation changes
useEffect(() => {
if (!activeId) {
setMessages([]);
return;
}
api<Message[]>(`/api/conversations/${activeId}/messages`)
.then(setMessages)
.catch(() => {});
}, [activeId]);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingText]);
// Socket.io setup
useEffect(() => {
const socket = getSocket();
socket.connect();
socket.on('agent:text', (data: { conversationId: string; text: string }) => {
setStreamingText((prev) => prev + data.text);
});
socket.on('agent:start', () => {
setIsStreaming(true);
setStreamingText('');
});
socket.on('agent:end', (data: { conversationId: string }) => {
setIsStreaming(false);
setStreamingText('');
// Reload messages to get the final persisted version
api<Message[]>(`/api/conversations/${data.conversationId}/messages`)
.then(setMessages)
.catch(() => {});
});
socket.on('error', (data: { error: string }) => {
setIsStreaming(false);
setStreamingText('');
setMessages((prev) => [
...prev,
{
id: `error-${Date.now()}`,
conversationId: '',
role: 'system',
content: `Error: ${data.error}`,
createdAt: new Date().toISOString(),
},
]);
});
return () => {
socket.off('agent:text');
socket.off('agent:start');
socket.off('agent:end');
socket.off('error');
socket.disconnect();
};
}, []);
const handleNewConversation = useCallback(async () => {
const conv = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: 'New conversation' },
});
setConversations((prev) => [conv, ...prev]);
setActiveId(conv.id);
setMessages([]);
}, []);
const handleSend = useCallback(
async (content: string) => {
let convId = activeId;
// Auto-create conversation if none selected
if (!convId) {
const conv = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: content.slice(0, 50) },
});
setConversations((prev) => [conv, ...prev]);
setActiveId(conv.id);
convId = conv.id;
}
// Optimistic user message
const userMsg: Message = {
id: `temp-${Date.now()}`,
conversationId: convId,
role: 'user',
content,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
// Persist user message
await api<Message>(`/api/conversations/${convId}/messages`, {
method: 'POST',
body: { role: 'user', content },
});
// Send to WebSocket for streaming response
const socket = getSocket();
socket.emit('message', { conversationId: convId, content });
},
[activeId],
);
return (
<div className="-m-6 flex h-[calc(100vh-3.5rem)]">
<ConversationList
conversations={conversations}
activeId={activeId}
onSelect={setActiveId}
onNew={handleNewConversation}
/>
<div className="flex flex-1 flex-col">
{activeId ? (
<>
<div className="flex-1 space-y-4 overflow-y-auto p-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{isStreaming && <StreamingMessage text={streamingText} />}
<div ref={messagesEndRef} />
</div>
<ChatInput onSend={handleSend} disabled={isStreaming} />
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h2 className="text-lg font-medium text-text-secondary">Welcome to Mosaic Chat</h2>
<p className="mt-1 text-sm text-text-muted">
Select a conversation or start a new one
</p>
<button
type="button"
onClick={handleNewConversation}
className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
Start new conversation
</button>
</div>
</div>
)}
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
import { AppShell } from '@/components/layout/app-shell';
import { AuthGuard } from '@/components/auth-guard';
export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<AuthGuard>
<AppShell>{children}</AppShell>
</AuthGuard>
);
}
@@ -0,0 +1,96 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import type { Project } from '@/lib/types';
import { ProjectCard } from '@/components/projects/project-card';
export default function ProjectsPage(): React.ReactElement {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Project[]>('/api/projects')
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleProjectClick = useCallback((project: Project) => {
console.log('Project clicked:', project.id);
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold">Projects</h1>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
) : projects.length === 0 ? (
<div className="py-12 text-center">
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
<p className="mt-1 text-sm text-text-muted">
Projects will appear here when created via the gateway API
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} onClick={handleProjectClick} />
))}
</div>
)}
{/* Mission status section */}
<MissionStatus />
</div>
);
}
function MissionStatus(): React.ReactElement {
const [mission, setMission] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Record<string, unknown>>('/api/coord/status')
.then(setMission)
.catch(() => setMission(null))
.finally(() => setLoading(false));
}, []);
return (
<section className="mt-8">
<h2 className="mb-4 text-lg font-semibold">Active Mission</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading mission status...</p>
) : !mission ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
<p className="text-sm text-text-muted">No active mission detected</p>
</div>
) : (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Mission" value={String(mission['missionId'] ?? 'Unknown')} />
<StatCard label="Phase" value={String(mission['currentPhase'] ?? '—')} />
<StatCard
label="Tasks"
value={`${mission['completedTasks'] ?? 0} / ${mission['totalTasks'] ?? 0}`}
/>
<StatCard label="Status" value={String(mission['status'] ?? '—')} />
</div>
</div>
)}
</section>
);
}
function StatCard({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="rounded-lg bg-surface-elevated p-3">
<p className="text-xs text-text-muted">{label}</p>
<p className="mt-1 text-sm font-medium text-text-primary">{value}</p>
</div>
);
}
@@ -0,0 +1,150 @@
'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { useSession } from '@/lib/auth-client';
interface ProviderInfo {
name: string;
enabled: boolean;
modelCount: number;
}
interface ModelInfo {
id: string;
name: string;
provider: string;
contextWindow: number;
reasoning: boolean;
cost: { input: number; output: number };
}
export default function SettingsPage(): React.ReactElement {
const { data: session } = useSession();
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api<ProviderInfo[]>('/api/providers').catch(() => []),
api<ModelInfo[]>('/api/providers/models').catch(() => []),
])
.then(([p, m]) => {
setProviders(p);
setModels(m);
})
.finally(() => setLoading(false));
}, []);
return (
<div className="mx-auto max-w-3xl space-y-8">
<h1 className="text-2xl font-semibold">Settings</h1>
{/* Profile */}
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Profile</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
{session?.user ? (
<div className="space-y-3">
<Field label="Name" value={session.user.name ?? '—'} />
<Field label="Email" value={session.user.email} />
<Field label="User ID" value={session.user.id} />
</div>
) : (
<p className="text-sm text-text-muted">Not signed in</p>
)}
</div>
</section>
{/* Providers */}
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">LLM Providers</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading providers...</p>
) : providers.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<p className="text-sm text-text-muted">No providers configured</p>
</div>
) : (
<div className="space-y-3">
{providers.map((p) => (
<div
key={p.name}
className="flex items-center justify-between rounded-lg border border-surface-border bg-surface-card p-4"
>
<div>
<p className="text-sm font-medium text-text-primary">{p.name}</p>
<p className="text-xs text-text-muted">{p.modelCount} models available</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs ${
p.enabled ? 'bg-success/20 text-success' : 'bg-gray-600/20 text-gray-400'
}`}
>
{p.enabled ? 'Active' : 'Disabled'}
</span>
</div>
))}
</div>
)}
</section>
{/* Models */}
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Available Models</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading models...</p>
) : models.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<p className="text-sm text-text-muted">No models available</p>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-surface-border">
<table className="w-full">
<thead>
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Model</th>
<th className="px-4 py-2 font-medium">Provider</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
</tr>
</thead>
<tbody>
{models.map((m) => (
<tr
key={`${m.provider}-${m.id}`}
className="border-b border-surface-border last:border-b-0"
>
<td className="px-4 py-2 text-sm text-text-primary">
{m.name}
{m.reasoning && (
<span className="ml-2 text-xs text-purple-400">reasoning</span>
)}
</td>
<td className="px-4 py-2 text-xs text-text-muted">{m.provider}</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{(m.contextWindow / 1000).toFixed(0)}k
</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
${m.cost.input} / ${m.cost.output}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
function Field({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-text-muted">{label}</span>
<span className="text-sm text-text-primary">{value}</span>
</div>
);
}
@@ -0,0 +1,72 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskListView } from '@/components/tasks/task-list-view';
type ViewMode = 'list' | 'kanban';
export default function TasksPage(): React.ReactElement {
const [tasks, setTasks] = useState<Task[]>([]);
const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Task[]>('/api/tasks')
.then(setTasks)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleTaskClick = useCallback((task: Task) => {
// Task detail view will be added in future iteration
console.log('Task clicked:', task.id);
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-surface-border">
<button
type="button"
onClick={() => setView('list')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'list'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
List
</button>
<button
type="button"
onClick={() => setView('kanban')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'kanban'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
Kanban
</button>
</div>
</div>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : view === 'kanban' ? (
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
) : (
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
)}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
@import 'tailwindcss';
/*
* Mosaic Stack design tokens mapped to Tailwind v4 theme.
* Source: @mosaic/design-tokens (AD-13)
* Fonts: Outfit (sans), Fira Code (mono)
* Palette: deep blue-grays + blue/purple/teal accents
* Default: dark theme
*/
@theme {
/* ─── Fonts ─── */
--font-sans: 'Outfit', system-ui, -apple-system, sans-serif;
--font-mono: 'Fira Code', ui-monospace, Menlo, monospace;
/* ─── Neutral blue-gray scale ─── */
--color-gray-50: #f0f2f5;
--color-gray-100: #dce0e8;
--color-gray-200: #b8c0cc;
--color-gray-300: #8e99a9;
--color-gray-400: #6b7a8d;
--color-gray-500: #4e5d70;
--color-gray-600: #3b4859;
--color-gray-700: #2a3544;
--color-gray-800: #1c2433;
--color-gray-900: #111827;
--color-gray-950: #0a0f1a;
/* ─── Primary — blue ─── */
--color-blue-50: #eff4ff;
--color-blue-100: #dae5ff;
--color-blue-200: #bdd1ff;
--color-blue-300: #8fb4ff;
--color-blue-400: #5b8bff;
--color-blue-500: #3b6cf7;
--color-blue-600: #2551e0;
--color-blue-700: #1d40c0;
--color-blue-800: #1e369c;
--color-blue-900: #1e317b;
--color-blue-950: #162050;
/* ─── Accent — purple ─── */
--color-purple-50: #f3f0ff;
--color-purple-100: #e7dfff;
--color-purple-200: #d2c3ff;
--color-purple-300: #b49aff;
--color-purple-400: #9466ff;
--color-purple-500: #7c3aed;
--color-purple-600: #6d28d9;
--color-purple-700: #5b21b6;
--color-purple-800: #4c1d95;
--color-purple-900: #3b1578;
--color-purple-950: #230d4d;
/* ─── Accent — teal ─── */
--color-teal-50: #effcf9;
--color-teal-100: #d0f7ef;
--color-teal-200: #a4eddf;
--color-teal-300: #6fddcb;
--color-teal-400: #3ec5b2;
--color-teal-500: #25aa99;
--color-teal-600: #1c897e;
--color-teal-700: #1b6e66;
--color-teal-800: #1a5853;
--color-teal-900: #194945;
--color-teal-950: #082d2b;
/* ─── Semantic surface tokens ─── */
--color-surface-bg: #0a0f1a;
--color-surface-card: #111827;
--color-surface-elevated: #1c2433;
--color-surface-border: #2a3544;
/* ─── Semantic text tokens ─── */
--color-text-primary: #f0f2f5;
--color-text-secondary: #8e99a9;
--color-text-muted: #6b7a8d;
/* ─── Status colors ─── */
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
/* ─── Sidebar width ─── */
--spacing-sidebar: 16rem;
}
/* ─── Base styles ─── */
body {
background-color: var(--color-surface-bg);
color: var(--color-text-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ─── Scrollbar styling ─── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-gray-600);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-500);
}
+30
View File
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react';
import { Outfit, Fira_Code } from 'next/font/google';
import './globals.css';
const outfit = Outfit({
subsets: ['latin'],
variable: '--font-sans',
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
});
const firaCode = Fira_Code({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500', '700'],
});
export const metadata = {
title: 'Mosaic',
description: 'Mosaic Stack Dashboard',
};
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<html lang="en" className={`dark ${outfit.variable} ${firaCode.variable}`}>
<body>{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function HomePage(): never {
redirect('/chat');
}
+34
View File
@@ -0,0 +1,34 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSession } from '@/lib/auth-client';
interface AuthGuardProps {
children: React.ReactNode;
}
export function AuthGuard({ children }: AuthGuardProps): React.ReactElement | null {
const { data: session, isPending } = useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && !session) {
router.replace('/login');
}
}, [isPending, session, router]);
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (!session) {
return null;
}
return <>{children}</>;
}
@@ -0,0 +1,53 @@
'use client';
import { useRef, useState } from 'react';
interface ChatInputProps {
onSend: (content: string) => void;
disabled?: boolean;
}
export function ChatInput({ onSend, disabled }: ChatInputProps): React.ReactElement {
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
function handleSubmit(e: React.FormEvent): void {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue('');
textareaRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}
return (
<form onSubmit={handleSubmit} className="border-t border-surface-border bg-surface-card p-4">
<div className="flex items-end gap-3">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
rows={1}
placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
className="max-h-32 min-h-[2.5rem] flex-1 resize-none rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
/>
<button
type="submit"
disabled={disabled || !value.trim()}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</div>
</form>
);
}
@@ -0,0 +1,57 @@
'use client';
import { cn } from '@/lib/cn';
import type { Conversation } from '@/lib/types';
interface ConversationListProps {
conversations: Conversation[];
activeId: string | null;
onSelect: (id: string) => void;
onNew: () => void;
}
export function ConversationList({
conversations,
activeId,
onSelect,
onNew,
}: ConversationListProps): React.ReactElement {
return (
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
<div className="flex items-center justify-between p-3">
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
<button
type="button"
onClick={onNew}
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
>
+ New
</button>
</div>
<div className="flex-1 overflow-y-auto">
{conversations.length === 0 && (
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
)}
{conversations.map((conv) => (
<button
key={conv.id}
type="button"
onClick={() => onSelect(conv.id)}
className={cn(
'w-full px-3 py-2 text-left text-sm transition-colors',
activeId === conv.id
? 'bg-blue-600/20 text-blue-400'
: 'text-text-secondary hover:bg-surface-elevated',
)}
>
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
<span className="block text-xs text-text-muted">
{new Date(conv.updatedAt).toLocaleDateString()}
</span>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
'use client';
import { cn } from '@/lib/cn';
import type { Message } from '@/lib/types';
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
const isUser = message.role === 'user';
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[75%] rounded-xl px-4 py-3 text-sm',
isUser
? 'bg-blue-600 text-white'
: 'border border-surface-border bg-surface-elevated text-text-primary',
)}
>
<div className="whitespace-pre-wrap break-words">{message.content}</div>
<div
className={cn('mt-1 text-right text-xs', isUser ? 'text-blue-200' : 'text-text-muted')}
>
{new Date(message.createdAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
'use client';
/** Renders an in-progress assistant message from streaming text. */
interface StreamingMessageProps {
text: string;
}
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement | null {
if (!text) return null;
return (
<div className="flex justify-start">
<div className="max-w-[75%] rounded-xl border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-primary">
<div className="whitespace-pre-wrap break-words">{text}</div>
<div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" />
Thinking...
</div>
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSession } from '@/lib/auth-client';
interface GuestGuardProps {
children: React.ReactNode;
}
/** Redirects authenticated users away from auth pages. */
export function GuestGuard({ children }: GuestGuardProps): React.ReactElement | null {
const { data: session, isPending } = useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && session) {
router.replace('/chat');
}
}, [isPending, session, router]);
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (session) {
return null;
}
return <>{children}</>;
}
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import { Sidebar } from './sidebar';
import { Topbar } from './topbar';
interface AppShellProps {
children: ReactNode;
}
export function AppShell({ children }: AppShellProps): React.ReactElement {
return (
<div className="min-h-screen">
<Sidebar />
<div className="pl-sidebar">
<Topbar />
<main className="p-6">{children}</main>
</div>
</div>
);
}

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