Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
84 KiB
Agent Platform Architecture — Slash Commands, Workspaces, Task Orchestration & Agent Isolation
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Date: 2026-03-15
Status: Augmented (2026-03-15)
Packages: packages/types, packages/cli, packages/queue, packages/coord, packages/db, apps/gateway
Problem Statement
The Mosaic TUI currently sends all user input directly to the agent via Socket.IO. There is no mechanism for in-session slash commands, user preferences, session overrides, or gateway-driven command discovery. Users must exit the TUI to perform operations like switching models, managing missions, or changing agent configs.
Beyond the command interface, the platform lacks: structured workspaces for multi-user project isolation, a unified task orchestration layer (DB and file-based systems are disconnected), agent sandboxing to prevent cross-user data access, session artifact garbage collection, and a gateway-owned command registry.
This plan establishes the foundational architecture for these systems.
Goals
- Slash command system — parse, validate, and execute
/commandsfrom the TUI input bar - Gateway-owned command registry — the gateway serves a typed command manifest; TUI consumes it
- Preference stack — four-layer override system (platform defaults → agent config → user preferences → session
/systemoverrides) - Hot reload — soft-restart the gateway to load new plugins/skills/commands without dropping connections
- Local primitives — baseline commands that work even when disconnected from the gateway
- Workspaces — structured, git-backed, per-user/per-project filesystem layout with chroot isolation
- Task orchestration — unified
@mosaic/queuelayer bridging PG, workspace files, and Valkey for agent task assignment - Session garbage collection — three-tier GC (session, sweep, full cold-start) across Valkey, PG, and filesystem
Architecture
Override Precedence Stack
┌─────────────────────────────────────┐
│ /system (session ephemeral) │ ← highest priority, Valkey-backed
├─────────────────────────────────────┤
│ /preferences (user persistent) │ ← per-user, stored in PG via gateway
├─────────────────────────────────────┤
│ Agent config (systemPrompt) │ ← per-agent, stored in agentConfigs
├─────────────────────────────────────┤
│ Platform defaults (Mosaic ships) │ ← base layer, enforcements + defaults
└─────────────────────────────────────┘
Each layer is additive by default. Destructive overrides require explicit syntax (TBD in implementation).
/system overrides are stored in Valkey keyed by session ID (mosaic:session:{sessionId}:system). They survive context compaction because they are never part of the message context — the gateway re-injects them from Valkey when constructing the system prompt for each agent turn.
Command Registry Flow
┌─────────────────────────────────────────────────┐
│ Gateway │
│ │
│ CommandRegistryService │
│ ├── core commands (always present) │
│ ├── agent-scoped commands (from agent config) │
│ ├── skill commands (from loaded skills) │
│ ├── plugin commands (discord/telegram may add) │
│ └── admin commands (system-level, RBAC-gated) │
│ │
│ On change → emit 'commands:manifest' to clients │
└──────────────┬──────────────────────────────────┘
│ Socket.IO
▼
┌──────────────────────────────────┐
│ TUI Client │
│ │
│ Local command manifest │
│ (received from gateway) │
│ + client-only commands │
│ (/help, /stop, /clear local) │
│ │
│ Merged manifest feeds: │
│ - autocomplete on "/" prefix │
│ - /help rendering │
│ - argument validation │
└──────────────────────────────────┘
Hot Reload Flow
Admin triggers reload
(via /reload command, POST /api/admin/reload, or SIGHUP)
│
▼
┌─────────────────────────────────────────────┐
│ Gateway ReloadService │
│ │
│ 1. Acquire reload lock (prevent race) │
│ 2. Snapshot current state │
│ 3. Re-scan skills directories │
│ 4. Re-init plugin lifecycles │
│ - plugin.onUnload() → cleanup │
│ - plugin.onLoad() → re-register │
│ 5. Rebuild command registry │
│ 6. Diff old manifest vs new manifest │
│ 7. Broadcast 'system:reload' to sessions │
│ 8. Release lock │
│ │
│ On failure → rollback to snapshot │
└─────────┬───────────────────────────────────┘
│ Socket.IO broadcast
▼
┌──────────────────────────────────┐
│ All connected TUIs │
│ │
│ 'system:reload' event │
│ { commands, skills, providers, │
│ message } │
│ │
│ TUI patches local state, shows │
│ status notification │
└──────────────────────────────────┘
Plugin Lifecycle Contract (required for hot reload)
interface MosaicPlugin {
id: string;
onLoad(ctx: PluginContext): Promise<void>;
onUnload(ctx: PluginContext): Promise<void>;
onReload?(ctx: PluginContext): Promise<void>;
}
Without onUnload, hot-reload is impossible — would leak listeners, duplicate registrations, orphan connections.
Command List
Session Commands
| Command | Short | Args | Execution | RBAC | Description |
|---|---|---|---|---|---|
/new |
/n |
{fresh}? |
socket | user | Start new session. /new fresh also GC-collects the old session's artifacts. |
/clear |
— | — | socket | user | Clear current session context (server-side context reset, keep session ID). Triggers session GC (Valkey overrides, log demotion). |
/compact |
— | {instructions}? |
socket | user | Trigger context compaction. Optional custom instructions appended. |
/rename |
— | {name}? |
REST | user | Name/rename current session. No arg → prompt inline. |
/resume |
— | {session}? |
REST + socket | user | Resume by name or ID. No arg → show session picker. |
/history |
— | {n}? |
REST | user | Show last N sessions or messages. |
/export |
— | {format}? |
REST | user | Export conversation (md, json). |
/stop |
— | — | local + socket | user | Cancel in-progress streaming. Also triggered by Esc while streaming. |
/retry |
— | — | socket | user | Re-send last user message with current settings. Distinct from up-arrow (input history recall for editing). |
/gc |
— | — | REST | user | Trigger sweep GC. Admin sees system-wide results; regular user scoped to their own orphans. |
Model & Provider Commands
| Command | Short | Args | Execution | RBAC | Description |
|---|---|---|---|---|---|
/model |
/m |
{name}? |
socket | user | Switch model. No arg → provider-grouped model picker. |
/provider |
— | {login|logout}? {name}? |
REST + socket | user | List providers. Subcommands for OAuth login/logout. |
/thinking |
/t |
{low|medium|high|xhigh|auto} |
socket | user | Set thinking level directly (not cycle). |
/provider collapses the original /login and /logout into subcommands:
/provider→ list available providers + connection status/provider login→ show OAuth provider picker/provider login anthropic→ direct OAuth login/provider logout openrouter→ direct logout
Agent & Tools Commands
| Command | Short | Args | Execution | RBAC | Description |
|---|---|---|---|---|---|
/agent |
/a |
{name}? |
REST + socket | user | Switch agent config. No arg → agent picker. |
/tools |
— | — | local (from manifest) | user | List available tools and enabled/disabled state. |
/skill:* |
— | {args}? |
socket | user | Invoke installed skill by name. Follows Pi/Agent Skills standard pattern. |
Preferences & Override Commands
| Command | Short | Args | Execution | RBAC | Description |
|---|---|---|---|---|---|
/preferences |
/pref |
{show|set|reset} |
REST | user | Persistent user preferences. Stored in PG. Survives sessions. |
/system |
— | {prompt}? |
socket | user | Session-scoped override. Stored in Valkey. Survives compaction. |
Mission & Planning Commands
| Command | Short | Args | Execution | RBAC | Description |
|---|---|---|---|---|---|
/mission |
— | {subcommand}? |
REST | user | Mission status/management. |
/prdy |
— | {init|update}? |
REST + wizard | user | Launch PRD wizard in-session. |
/mission subcommands:
/mission→ show active mission summary (name, phase, status, task count)/mission list→ list missions/mission tasks→ list tasks for active mission/mission set {name}→ set active mission context for session. Triggers the agent to begin working on the chosen mission. Requires user confirmation before proceeding (destructive context switch). Mission context (name, description, phase, tasks) is injected into the system prompt so the agent understands its objective.
Status & Info Commands
| Command | Short | Args | Execution | RBAC | Description |
|---|---|---|---|---|---|
/status |
/s |
— | local + REST | user | Full session status dump: model, provider, agent, connection, tokens, cost, context %, thinking level, active mission, loaded skills. |
/cost |
— | — | local | user | Cumulative session cost + token breakdown. |
/help |
/h |
{command}? |
local (from manifest) | user | List all commands, or show help for specific command. |
Admin Commands
| Command | Short | Args | Execution | RBAC | Description |
|---|---|---|---|---|---|
/reload |
— | — | REST | admin | Soft-restart: reload skills, plugins, command registry. Maintain connections. Push updates to sessions. |
Future Commands (Not In Scope)
| Command | Description |
|---|---|
/share |
Share conversation link (1:1, group). Similar to ChatGPT sharing. |
/fork |
Branch conversation from a specific message point. |
/copy |
Copy last assistant response to clipboard. |
Type Contracts (@mosaic/types)
CommandDef — Gateway Command Manifest Entry
/** Argument definition for a slash command */
export interface CommandArgDef {
name: string;
type: 'string' | 'enum';
optional: boolean;
/** For enum type, the allowed values */
values?: string[];
description?: string;
}
/** A single command definition served by the gateway */
export interface CommandDef {
/** Command name without slash prefix, e.g. "model" */
name: string;
/** Short aliases, e.g. ["m"] */
aliases: string[];
/** Human-readable description */
description: string;
/** Argument schema */
args?: CommandArgDef[];
/** Nested subcommands (e.g. provider → login, logout) */
subcommands?: CommandDef[];
/** Origin of this command */
scope: 'core' | 'agent' | 'skill' | 'plugin' | 'admin';
/** Where the command executes */
execution: 'local' | 'socket' | 'rest' | 'hybrid';
/** Whether this command is currently available (provider connected, RBAC allows, etc.) */
available: boolean;
}
/** Full command manifest pushed from gateway to TUI */
export interface CommandManifest {
commands: CommandDef[];
skills: SkillCommandDef[];
/** Manifest version — TUI compares to detect changes */
version: number;
}
/** Skill registered as /skill:name */
export interface SkillCommandDef {
/** Skill name (used as /skill:{name}) */
name: string;
description: string;
/** Whether the skill is currently loaded and available */
available: boolean;
}
New Socket Events
/** Payload for commands:manifest event */
export interface CommandManifestPayload {
manifest: CommandManifest;
}
/** Payload for system:reload broadcast */
export interface SystemReloadPayload {
commands: CommandDef[];
skills: SkillCommandDef[];
providers: string[];
message: string;
}
/** Client request to execute a slash command via socket */
export interface SlashCommandPayload {
conversationId: string;
command: string;
args?: string;
}
/** Server response to a slash command */
export interface SlashCommandResultPayload {
conversationId: string;
command: string;
success: boolean;
message?: string;
data?: Record<string, unknown>;
}
// ── Add to ServerToClientEvents ──
export interface ServerToClientEvents {
// ... existing events ...
'commands:manifest': (payload: CommandManifestPayload) => void;
'command:result': (payload: SlashCommandResultPayload) => void;
'system:reload': (payload: SystemReloadPayload) => void;
}
// ── Add to ClientToServerEvents ──
export interface ClientToServerEvents {
// ... existing events ...
'command:execute': (data: SlashCommandPayload) => void;
}
/system Override — Valkey Storage Design
Key Schema
mosaic:session:{sessionId}:system → string (the override prompt text)
TTL & Session Lifetime
Problem: Users leave TUI sessions open for days. Discord/Telegram bot sessions are effectively infinite (run until the bot stops). A fixed 24-hour TTL would silently destroy /system overrides.
Solution: Activity-based TTL renewal.
The Valkey key uses a generous base TTL (7 days) that is renewed on every interaction:
| Event | TTL Action |
|---|---|
/system override set |
SET with 7-day TTL |
| Any agent prompt in session | EXPIRE renewed to 7 days |
| Socket heartbeat (every 5 min) | EXPIRE renewed to 7 days |
/system clear |
DEL immediately |
/new (new session) |
DEL immediately |
| Session destroyed (explicit) | DEL immediately |
| No activity for 7 days | Expires automatically (orphan cleanup) |
Channel-type considerations:
| Channel | Session Lifetime | TTL Strategy |
|---|---|---|
| TUI (websocket) | Minutes to days | Activity-renewed, cleaned on disconnect if session ends |
| Discord | Weeks to months | Activity-renewed, per-channel key (mosaic:session:{channelId}:system) |
| Telegram | Weeks to months | Activity-renewed, per-chat key |
| REST API | Per-request | N/A — no persistent session overrides |
For Discord/Telegram, the "session" is really the channel/chat — overrides persist as long as the bot is active in that channel. The key schema accommodates this:
mosaic:session:{sessionId}:system # TUI sessions
mosaic:channel:{channelType}:{channelId}:system # Plugin channel sessions
Lifecycle
- User sends
/system Always respond in bullet points - TUI emits
command:executewith{ command: "system", args: "Always respond in bullet points" } - Gateway
CommandExecutorServicewrites to Valkey:SET mosaic:session:{sid}:system "Always respond in bullet points" EX 604800 - Gateway responds with
command:resultconfirming the override is set - User later sends
/system Use numbered lists instead - Gateway reads existing override from Valkey, appends new override
- Gateway calls a condensation step: the accumulated overrides are distilled by the agent/gateway into a single coherent override using last-wins semantics (e.g., "numbered lists" supersedes "bullet points")
- Gateway writes the condensed result back to Valkey:
SET mosaic:session:{sid}:system "<condensed>" EX 604800 - On every subsequent agent turn, gateway's prompt assembly reads the Valkey key and layers it as the highest-priority system prompt override (after platform defaults, after agent config, after user preferences)
- On compaction, the override is NOT in the message context — it's re-injected from Valkey, so it survives intact
/systemwith no args → gateway reads the key and returns current override incommand:result/system clear→ gateway DELs the key- On session end /
/new→ key is explicitly deleted - On every prompt, the key's TTL is refreshed via
EXPIRE
Override Condensation
When multiple /system overrides accumulate, the gateway condenses them to prevent unbounded growth:
User: /system Always respond in bullet points
Valkey: "Always respond in bullet points"
User: /system Use numbered lists instead
Gateway reads existing: "Always respond in bullet points"
Gateway appends: "Always respond in bullet points\n---\nUse numbered lists instead"
Gateway condenses (LLM call): "Use numbered lists for all responses"
Valkey: "Use numbered lists for all responses"
User: /system Include code examples when relevant
Gateway reads existing: "Use numbered lists for all responses"
Gateway appends + condenses: "Use numbered lists for all responses. Include code examples when relevant."
Valkey: "Use numbered lists for all responses. Include code examples when relevant."
The condensation step is a lightweight LLM call (cheap model, small context) that merges $EXISTING_OVERRIDE + $NEW_OVERRIDE into a single coherent instruction set, applying last-wins for contradictions. This prevents creep while preserving cumulative non-conflicting preferences.
Using Existing Queue Package
The @mosaic/queue package already provides createQueue() returning an ioredis handle on redis://localhost:6380. The /system storage will use the same Valkey instance directly via the redis handle — no queue semantics needed, just SET/GET/DEL/EXPIRE.
/preferences — Schema Design
Storage
Postgres via @mosaic/db. The preferences table already exists in packages/db/src/schema.ts with the right shape:
// Existing schema — already has category + key + value JSONB
export const preferences = pgTable('preferences', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
value: jsonb('value').notNull(),
category: text('category', {
enum: ['communication', 'coding', 'workflow', 'appearance', 'general'],
})
.notNull()
.default('general'),
source: text('source'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
The existing category enum covers user-facing groupings. System-level categories (session, safety, limits) may need to be added to the enum via migration. A mutable boolean column should be added to distinguish user-adjustable preferences from enforcements.
The key is the unique identifier (e.g., response.language). The category is the display grouping. This keeps queries flat (WHERE user_id = $1 AND key = $2) while supporting grouped display (GROUP BY category).
Platform Defaults
Mosaic ships with a seed set of preferences. Stored as defaults in code (not in DB until user mutates them). The mutable flag distinguishes user-adjustable preferences from platform enforcements. Enforcements are never written to the DB — they are applied in code unconditionally and cannot be overridden via /preferences set.
| Key | Default Value | Mutable | Category |
|---|---|---|---|
response.language |
"auto" (follows locale) |
✓ | response |
response.codeAnnotations |
true |
✓ | response |
safety.confirmDestructiveTools |
true |
✓ | safety |
session.autoCompactThreshold |
0.80 |
✓ | session |
session.autoCompactEnabled |
true |
✓ | session |
limits.maxThinkingLevel |
(per role) | ✗ (enforcement) | limits |
limits.rateLimit |
(per role) | ✗ (enforcement) | limits |
/preferences set rejects writes to immutable keys with a clear message:
⚙ Cannot override "limits.maxThinkingLevel" — this is a platform enforcement. Contact your admin.
Merge Logic
When constructing the effective preference set for a request:
- Start with platform defaults (hardcoded)
- Overlay with user preferences from DB (only mutable keys)
- Enforcements from platform defaults are re-applied last (cannot be overridden)
Per-Project RBAC (Future)
Each project can define user-level RBAC for shared projects. This affects which preferences are visible/editable and which commands are available. The user_preferences table is user-global; project-scoped preference overrides would be a separate table (project_user_preferences) with a project_id foreign key. Deferred to RBAC phase.
Hot Reload — Reloadable vs Static Components
| Component | Hot-Reloadable | Reason |
|---|---|---|
| Skills | ✓ | File-based, scan + re-register |
| Plugins (discord/telegram) | ✓ | Module-level lifecycle hooks |
| Command registry | ✓ | Derived from skills + plugins + config |
| Preference defaults | ✓ | Config/DB read |
| Provider configs | ✓ | DB/env read |
| NestJS modules/routes | ✗ | Fastify listener can't hot-swap routes |
| DB schema | ✗ | Requires migration + restart |
| Auth config | ✗ | BetterAuth bootstraps once |
Reload Triggers
| Trigger | Use Case |
|---|---|
/reload slash command |
Admin in TUI session |
POST /api/admin/reload |
REST call from CI/CD, webhook, or admin dashboard |
SIGHUP signal |
Ops/systemd convention |
| File watcher (dev mode only) | Watch skills directories for changes |
TUI-Side Command Parsing
Input Interception
In InputBar.handleSubmit, before sending to socket:
function handleSubmit(value: string) {
const trimmed = value.trim();
// Slash command detection
if (trimmed.startsWith('/')) {
const parsed = parseSlashCommand(trimmed);
if (parsed) {
executeCommand(parsed);
return;
}
// Unknown command — show error inline, don't send to agent
showSystemMessage(`Unknown command: ${trimmed.split(' ')[0]}`);
return;
}
// Normal message — send to agent
socket.sendMessage(value);
}
Parse Function
interface ParsedCommand {
command: string; // "model", "skill:brave-search"
args: string | null; // "claude-4" or null
raw: string; // "/model claude-4"
}
function parseSlashCommand(input: string): ParsedCommand | null {
const match = input.match(/^\/([a-z][a-z0-9:_-]*)\s*(.*)?$/i);
if (!match) return null;
return {
command: match[1]!,
args: match[2]?.trim() || null,
raw: input,
};
}
Execution Routing
function executeCommand(parsed: ParsedCommand) {
const def = findCommand(parsed.command); // search manifest + local commands
if (!def) {
showSystemMessage(`Unknown command: /${parsed.command}`);
return;
}
if (!def.available) {
showSystemMessage(`Command /${parsed.command} is not available (${reason})`);
return;
}
switch (def.execution) {
case 'local':
executeLocal(parsed, def); // /help, /stop, /cost
break;
case 'socket':
socket.emit('command:execute', {
conversationId,
command: parsed.command,
args: parsed.args ?? undefined,
});
break;
case 'rest':
executeRest(parsed, def); // /rename, /preferences, /export
break;
case 'hybrid':
executeHybrid(parsed, def); // /provider login, /status
break;
}
}
Local-Only Commands (Work Offline)
These commands must function even when the gateway is disconnected:
| Command | Behavior When Offline |
|---|---|
/help |
Show commands from cached manifest + local commands |
/stop |
Cancel local streaming state |
/cost |
Show locally tracked token/cost accumulator |
/status |
Show local state (model, connection: disconnected, tokens) |
System Messages
Command output renders as role: 'system' messages — visually distinct from user and assistant messages (e.g., dimmed, no avatar, centered or left-aligned with a ⚙ prefix). This requires adding 'system' to the Message.role union type in use-socket.ts.
Short Aliases
| Alias | Command |
|---|---|
/n |
/new |
/m |
/model |
/t |
/thinking |
/a |
/agent |
/s |
/status |
/h |
/help |
/pref |
/preferences |
Aliases are resolved in findCommand() before manifest lookup.
Implementation Phases
Phase 1: Types + Local Command Parsing (no gateway changes)
- Add
CommandDef,CommandManifest, new socket events to@mosaic/types - Add
parseSlashCommand()utility topackages/cli - Add
role: 'system'toMessagetype, render system messages inMessageList - Implement local-only commands:
/help,/stop,/cost,/status(local state only) - Wire command parsing into
InputBar.handleSubmit— intercept/prefix - Hardcode initial command manifest in TUI (temporary, replaced in Phase 2)
Phase 2: Gateway Command Registry
- Create
CommandRegistryServiceinapps/gateway - Register core commands with
CommandDefmetadata - Emit
commands:manifeston socket connect (alongsidesession:info) - Handle
command:executesocket event — route to appropriate service - Implement socket-executed commands:
/model,/thinking,/new,/clear,/compact,/retry - Implement REST-executed commands:
/rename,/resume,/history,/export - TUI replaces hardcoded manifest with gateway-provided manifest
Phase 3: Preferences & System Overrides
- Create
user_preferencestable in@mosaic/db, Drizzle schema + migration - Create
PreferencesServicein gateway — CRUD + defaults + enforcement logic - Implement
/preferencescommand (REST-executed) - Implement
/systemcommand — Valkey storage, session-scoped - Wire system override into prompt assembly (injected from Valkey on each turn)
- Wire user preferences into prompt assembly (merged with defaults)
Phase 4: Agent, Provider, Mission Commands
- Implement
/agent— switch agent config mid-session - Implement
/provider— list providers, login/logout subcommands (OAuth flow) - Implement
/missionsubcommands — status, list, tasks, set - Implement
/prdy— launch PRD wizard in-session - Implement
/tools— list available tools from agent config
Phase 5: Hot Reload
- Define
MosaicPluginlifecycle interface - Refactor discord/telegram plugins to implement
onLoad/onUnload - Create
ReloadServicein gateway — scan, diff, broadcast - Implement
/reloadadmin command - Add
POST /api/admin/reloadREST endpoint - Add
SIGHUPhandler - Emit
system:reloadto all connected sessions - TUI handles
system:reload— patch manifest, show notification
Phase 6: Session Garbage Collection
- Create
SessionGCServiceinapps/gateway— three tiers:collect(sessionId),sweepOrphans(),fullCollect() - Wire
collect()intoAgentService.destroySession()— clean Valkey keys, demote logs - Wire
collect()into/clearand/new freshcommand handlers - Implement
fullCollect()inonModuleInit— runs on every gateway cold start - Add sweep GC cron job to existing
CronService(daily at 4am default, configurable viaSESSION_GC_CRON) - Implement
/gcslash command — triggerssweepOrphans(), user-scoped for non-admins - Add
GCResult/GCSweepResult/FullGCResulttypes - Expose last GC run stats in
/statusfor admin users - Log all GC activity via OTEL spans
Phase 7: Workspaces
- Define
MOSAIC_ROOTconfiguration — env var, config file, install-time prompt - Create
WorkspaceServicein gateway — directory creation, git init/clone, path resolution - Create
ProjectBootstrapService— orchestrates the full project creation sequence (DB + workspace + agent + Discord channel) - Wire
sandboxDirinAgentService.createSession()to resolve from workspace path instead of env var - Harden file/git/shell tools — strict path validation, reject resolved paths outside
sandboxDir - Add
repoUrloptional field to project creation API - Wire
/prdyoutput todocs/directory in workspace - Add workspace cleanup to project deletion and user deletion flows
- Port Gatekeeper service from old codebase —
isSystem: trueagent, PR review/merge authority
Phase 8: Autocomplete & Polish
- Autocomplete provider in TUI — triggers on
/keystroke, opens sidebar-style command list - Filter commands as user types (fuzzy match on name + aliases)
- Show arg hints for selected command
- Skill commands (
/skill:*) dynamically populated from manifest - Up-arrow input history recall in InputBar
File Impact Summary
New Files
| Path | Description |
|---|---|
packages/types/src/commands/index.ts |
CommandDef, CommandManifest, socket event types |
packages/cli/src/tui/commands/parse.ts |
parseSlashCommand() utility |
packages/cli/src/tui/commands/registry.ts |
Client-side command registry + local commands |
packages/cli/src/tui/commands/local/*.ts |
Local command handlers (help, stop, cost, status) |
apps/gateway/src/commands/command-registry.service.ts |
Gateway CommandRegistryService |
apps/gateway/src/commands/command-executor.service.ts |
Gateway command execution routing |
apps/gateway/src/commands/commands.module.ts |
NestJS module |
apps/gateway/src/preferences/preferences.service.ts |
User preferences CRUD + defaults + enforcement |
apps/gateway/src/preferences/preferences.module.ts |
NestJS module |
apps/gateway/src/reload/reload.service.ts |
Hot reload orchestration |
apps/gateway/src/gc/session-gc.service.ts |
Session garbage collection (on-demand + sweep + full) |
apps/gateway/src/gc/gc.module.ts |
NestJS module |
apps/gateway/src/workspace/workspace.service.ts |
Workspace directory management, git init/clone, path resolution |
apps/gateway/src/workspace/workspace.module.ts |
NestJS module |
apps/gateway/src/workspace/project-bootstrap.service.ts |
Orchestrates full project creation sequence |
apps/gateway/src/gatekeeper/gatekeeper.service.ts |
PR review/merge agent service (ported from old codebase) |
apps/gateway/src/gatekeeper/gatekeeper.module.ts |
NestJS module |
Modified Files
| Path | Changes |
|---|---|
packages/types/src/chat/events.ts |
Add command socket events to Server/ClientToServerEvents |
packages/types/src/index.ts |
Re-export commands types |
packages/cli/src/tui/hooks/use-socket.ts |
Add role: 'system' to Message, handle command/manifest events |
packages/cli/src/tui/components/input-bar.tsx |
Intercept / prefix, route to command parser, up-arrow history |
packages/cli/src/tui/components/message-list.tsx |
Render system messages distinctly |
packages/cli/src/tui/app.tsx |
Wire command manifest state, pass to input bar |
apps/gateway/src/chat/chat.gateway.ts |
Handle command:execute, emit commands:manifest |
apps/gateway/src/agent/agent.service.ts |
Call SessionGCService.collect() from destroySession(), resolve sandboxDir from workspace |
apps/gateway/src/log/cron.service.ts |
Add GC sweep cron job alongside existing summarization/tier crons |
apps/gateway/src/agent/tools/shell-tools.ts |
Harden path validation — reject resolved paths outside sandboxDir |
apps/gateway/src/agent/tools/file-tools.ts |
Harden path validation — reject resolved paths outside sandboxDir |
apps/gateway/src/agent/tools/git-tools.ts |
Harden path validation — reject resolved paths outside sandboxDir |
Note: preferences Table Already Exists
The preferences table already exists in packages/db/src/schema.ts with category and key columns. No new migration is needed for basic preference storage — only schema adjustments if the mutable column or additional indexes are required.
Session Garbage Collection
Problem
When a session is terminated or deleted, artifacts accumulate across multiple stores:
| Store | Artifacts | Current Cleanup |
|---|---|---|
| Valkey | /system overrides, future session-scoped keys |
TTL-based expiry only |
| PG — agent_logs | Session-scoped log entries (session_id) |
Tier management (hot→warm→cold) but no deletion on session end |
| PG — messages | Conversation messages | CASCADE on conversation delete, but sessions ≠ conversations |
| PG — summarization_jobs | Completed/failed job records | None currently |
| In-memory | AgentService.sessions Map, Pi session objects, event listeners |
destroySession() clears these |
| Future: temp files | Files created by agent shell/file tools in sandbox | None |
Current State
AgentService.destroySession() handles in-memory cleanup (unsubscribe, dispose piSession, clear listeners/channels, delete from Map). But it does NOT clean up:
- Valkey keys for the session
- PG agent_logs tied to the session
- Temporary files in the sandbox directory
Proposed: SessionGarbageCollector
A service that runs both on-demand (triggered by session termination) and periodically (cron sweep for orphans).
GC Tiers
The GC system operates at three levels of scope, all using the same underlying SessionGCService:
| Tier | Trigger | Scope | Description |
|---|---|---|---|
| Session GC | destroySession(), /clear, /new fresh |
Single session | Clean artifacts for one specific session |
| Sweep GC | Cron (daily), /gc command |
All orphans | Scan for and collect orphaned artifacts across all stores |
| Full GC | Gateway cold start / restart | Everything | Aggressive pass — assumes no sessions survived the restart, cleans all stale artifacts |
Session GC (Single Session)
Triggered when a session is terminated, cleared, or explicitly collected. Called from:
AgentService.destroySession(sessionId)/clearcommand handler (context reset includes artifact cleanup)/new freshvariant (new session + GC the old one)
@Injectable()
export class SessionGCService {
constructor(
@Inject(QUEUE_HANDLE) private readonly queue: QueueHandle,
@Inject(BRAIN) private readonly brain: Brain,
) {}
/**
* Immediate cleanup for a single session.
*/
async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys
const valkeyKeys = await this.queue.redis.keys(`mosaic:session:${sessionId}:*`);
if (valkeyKeys.length > 0) {
await this.queue.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
// 2. PG: mark agent_logs for this session as cold tier
// (don't delete — tier management handles archival/deletion)
const logsUpdated = await this.brain.logs.markSessionCold(sessionId);
result.cleaned.logsDemoted = logsUpdated;
// 3. Future: clean sandbox temp files
// await this.cleanSandboxTempFiles(sessionId);
return result;
}
}
Sweep GC (Orphan Collection)
Scans all stores for artifacts whose owning session no longer exists. Triggered by:
- Daily cron job (configurable via
SESSION_GC_CRON, default0 4 * * *) /gcslash command (admin or user — user-scoped sweep only collects their own orphans)
// In CronService.onModuleInit()
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
this.tasks.push(
cron.schedule(gcSchedule, () => {
this.sessionGC.sweepOrphans().catch((err) => {
this.logger.error(`Session GC sweep failed: ${err}`);
});
}),
);
| Sweep Target | Detection | Action |
|---|---|---|
Valkey mosaic:session:* keys |
Session ID not in active sessions AND key TTL remaining < threshold | DEL |
Valkey mosaic:channel:* keys |
Channel no longer active in any plugin | DEL |
PG agent_logs with no active session |
session_id NOT IN (active session IDs) AND tier = 'hot' AND created_at < now() - interval '7 days' |
Demote to cold |
PG summarization_jobs |
status IN ('completed', 'failed') AND created_at < now() - interval '30 days' |
DELETE |
| Sandbox temp directories | Directory exists but session ID not in active sessions | rm -rf (with safety checks) |
Full GC (Cold Start)
Runs once during gateway bootstrap (onModuleInit), after all services are initialized but before accepting connections. A cold start means the in-memory session map is empty — every mosaic:session:* key in Valkey is an orphan from the previous process lifetime.
@Injectable()
export class SessionGCService implements OnModuleInit {
async onModuleInit(): Promise<void> {
this.logger.log('Running full GC on cold start...');
const result = await this.fullCollect();
this.logger.log(
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
`${result.logsDemoted} logs demoted, ` +
`${result.tempFilesRemoved} temp dirs removed ` +
`(${result.duration}ms)`,
);
}
/**
* Aggressive collection — assumes no sessions survived restart.
* All session-scoped Valkey keys are orphans.
* All hot-tier logs older than threshold are demoted.
*/
async fullCollect(): Promise<FullGCResult> {
const start = Date.now();
// 1. Valkey: delete ALL session-scoped keys (no active sessions exist)
const sessionKeys = await this.queue.redis.keys('mosaic:session:*');
if (sessionKeys.length > 0) {
await this.queue.redis.del(...sessionKeys);
}
// 2. Valkey: channel keys are NOT collected on cold start
// (plugins may reconnect and resume channels)
// 3. PG: demote all hot-tier logs older than 24h
// (recent logs may still be useful for debugging the restart)
const logsDemoted = await this.brain.logs.demoteStaleHotLogs('24 hours');
// 4. PG: purge old completed/failed summarization jobs
const jobsPurged = await this.brain.summarization.purgeOldJobs('30 days');
// 5. Sandbox: clean all temp directories
const tempFilesRemoved = await this.cleanAllSandboxTempDirs();
return {
valkeyKeys: sessionKeys.length,
logsDemoted,
jobsPurged,
tempFilesRemoved,
duration: Date.now() - start,
};
}
}
Channel keys are excluded from full GC — Discord/Telegram plugins reconnect after a gateway restart and may resume using existing channel overrides. Only the sweep GC (which checks plugin state) collects dead channel keys.
/clear and /new GC Variants
| Command | GC Behavior |
|---|---|
/clear |
Session GC on current session (clean Valkey overrides + demote logs), then reset context |
/new |
Create new session. Old session's in-memory state is dropped but artifacts linger until sweep/TTL |
/new fresh |
Create new session + Session GC on the old one. Clean break — nothing left behind |
/gc |
Trigger a sweep GC. Admin sees system-wide results; regular user sees only their orphaned artifacts |
GC Result Reporting
GC results are logged via OTEL and surfaced contextually:
interface GCResult {
sessionId: string;
cleaned: {
valkeyKeys?: number;
logsDemoted?: number;
tempFilesRemoved?: number;
};
}
interface GCSweepResult {
orphanedSessions: number;
totalCleaned: GCResult[];
duration: number;
}
interface FullGCResult {
valkeyKeys: number;
logsDemoted: number;
jobsPurged: number;
tempFilesRemoved: number;
duration: number;
}
| Context | Output |
|---|---|
| Cold start | Logger: Full GC complete: 14 Valkey keys, 230 logs demoted (340ms) |
/clear |
System message in TUI: ⚙ Session cleared. Cleaned 2 Valkey keys, 8 logs demoted. |
/new fresh |
System message: ⚙ New session started. Previous session artifacts collected. |
/gc |
System message: ⚙ GC sweep: 3 orphaned sessions, 7 Valkey keys, 42 logs demoted (120ms) |
| Cron sweep | Logger only (no TUI output) |
/status (admin) |
Includes last GC run time + result summary |
Resolved Design Decisions
- Autocomplete UX — Use the TUI sidebar panel (same pattern as conversation sidebar). Typing
/opens a filterable command list in the sidebar. Consistent with existing UI patterns. /mission set— Yes, injects mission context into system prompt. Requires user confirmation. See/missionsubcommands section above./preferencesgranularity — Flat key-value with acategorycolumn for display grouping. Already exists in schema aspreferencestable. See storage section above./systemmultiple overrides — Accumulate then condense. Gateway appends new override to existing, runs a lightweight LLM condensation pass with last-wins semantics, writes condensed result back to Valkey. Prevents unbounded growth. See "Override Condensation" section above.- Up-arrow input history — Needs implementation in InputBar. Independent of
/retry. Ubiquitous UX expectation — must ship. - RBAC granularity — Will support roles beyond admin/user. Per-project user RBAC for shared projects (team interaction). Deferred to RBAC phase but architecture accommodates it.
Resolved Open Questions (Round 2)
- Workspace-based file tracking — Structured workspace hierarchy under
$MOSAIC_ROOT/.workspaces/provides session-scoped directories, project-scoped repos, and user isolation. Replaces ad-hoc temp file tracking. See "Workspaces" section below. - GC on conversation delete — Aggressive. If a user deletes a conversation, they don't want the data. Important artifacts (files, DB entries) have already been persisted to workspaces/brain. GC destroys the agent session if active, cleans all Valkey keys, demotes logs. The conversation's messages cascade-delete via FK.
- Discord/Telegram channel GC — On bot disconnect from channel + admin command +
/gcinvoked in the channel itself. TTL remains as a safety net for anything the explicit triggers miss. - Condensation model — Configurable via
/settingscommand and web UI settings page, stored in DB as a system setting. Defaults to a fast/cheap model. Admin-adjustable.
Workspaces
Concept
Every user gets an isolated workspace rooted at $MOSAIC_ROOT/.workspaces/<user_id>/. Projects, sessions, repos, and planning artifacts are organized under this hierarchy. This replaces the current AGENT_FILE_SANDBOX_DIR / process.cwd() fallback with a structured, multi-user, project-aware filesystem layout.
All project workspaces are git repositories to enable rollback and change tracking. Mistakes happen — git history provides the safety net.
Directory Structure
$MOSAIC_ROOT/ # e.g. /opt/mosaic (Linux), configurable on install
└── .workspaces/
└── <user_id>/
└── <project_id>/
├── <git_repo>/ # Full working copy (primary agent workspace)
│ └── docs/
│ ├── PRD-<name>.md # PRD content (working copy, DB is SOT for state)
│ ├── <prd_name>-TASKS.md # Task breakdown
│ ├── plans/ # Implementation plans
│ └── reports/ # Agent reports, summaries
└── <git_repo>-worktrees/ # Git worktrees for parallel agent work
├── <branch-name>/ # Worktree checkouts created by agents
└── ...
Repo Type
Full working copies (not bare). Agents need to read/write files, which requires a checkout. Git worktrees also require a non-bare parent repo. Bare repos are for serving as remotes — that's Gitea/GitHub's job, not Mosaic's.
$MOSAIC_ROOT Ownership
- Owned by the mosaic service user (e.g.,
mosaic:mosaic) - Centrally located on install (prompted with platform-specific defaults)
- Linux:
/opt/mosaic - macOS:
/usr/local/mosaicor~/Library/Application Support/Mosaic - Windows:
C:\ProgramData\Mosaic
- Linux:
- Docker deployments: mounted volume / bind mount at container path
/opt/mosaic - The gateway process runs as the mosaic service user and has write access to all workspaces
Project Creation Behaviors
When a project is created, the gateway executes a consistent initialization sequence:
- Create the project in DB (
projectstable) - Create the project workspace —
$MOSAIC_ROOT/.workspaces/<user_id>/<project_id>/ - Initialize git repo —
git initthe project workspace (orgit clone <url>if remote upstream provided) - Create the agent config — default project agent in DB (
agentstable), linked to the project - Create docs structure —
docs/,docs/plans/,docs/reports/ - If Discord is configured — create the Discord category/channel for the project, link the agent to the channel
- Set agent sandboxDir — point the agent session's sandbox to the workspace repo
This is handled by a ProjectBootstrapService in the gateway — a single orchestrator that ensures consistency. Partial failures roll back cleanly.
Optional Remote Clone
Projects may optionally clone from a remote upstream on creation:
POST /api/projectsaccepts an optionalrepoUrlfield/project create --repo <url>in CLI- If provided,
git clone <url>replacesgit init - Remote tracking is configured automatically (
origin→ upstream) - Projects without a remote are local-only. Encouraged to add one later but not required.
Agent Working Model
| Agent | Works in | Purpose |
|---|---|---|
| Primary project agent | <git_repo>/ |
Main development, PRD creation, task execution |
| Parallel agents | <git_repo>-worktrees/<branch>/ |
Independent tasks that can run concurrently without conflicts |
| Gatekeeper agent | Read-only access to PRs/diffs | PR review and merge decisions. isSystem: true. Outside project agents' trust boundary. |
The primary agent works directly in the repo. When the agent determines that parallel work is needed (e.g., multiple independent tasks), it creates worktrees via git tools. This is agent-directed — the agent decides when parallelism is appropriate based on task dependencies.
Git Responsibility Model
Agents are given framework instructions for git operations:
| Operation | Responsible | Notes |
|---|---|---|
commit |
Agent | Agents commit their own work with meaningful messages |
push |
Agent | Push to remote when configured. Agent decides timing. |
pull / fetch |
Agent | Keep up to date with upstream before starting work |
branch |
Agent | Create feature branches for tasks |
worktree add/remove |
Agent | Manage parallel workspaces |
| PR creation | Agent | Agent creates the PR (via Gitea/GitHub/GitLab API tools) |
| PR review | Gatekeeper | Specialized agent service. Cannot be self-approved by the authoring agent. |
| PR merge | Gatekeeper | Only after quality gates pass (lint, typecheck, tests) |
Gatekeeper Service
A specialized agent service responsible for PR review and merge actions. Critical design constraints:
- Isolated trust boundary — project agents CANNOT approve or merge their own PRs
- System agent —
isSystem: true, not editable by users - Read-only code access — can read PR diffs, run quality checks, but cannot modify code
- Quality gates — lint, typecheck, test results must pass before merge is allowed
- Configurable strictness — projects can define required checks, minimum review depth
- Existed in old codebase — to be ported/adapted for mosaic-mono-v1
PRD/Task Files vs DB
DB is source of truth for structured state. Files are working copies for agent interaction.
| Layer | What it stores | Accessed by |
|---|---|---|
| DB (source of truth) | Mission status, phase, task status, assignees, timestamps, metadata | API, dashboard, /mission command, /status |
| Files (working copies) | PRD prose, specifications, plans, reports — the actual content | Agents (read/write naturally), users (review in git) |
The /prdy wizard writes markdown files to docs/ AND syncs structured metadata (mission name, tasks, phase) to the DB. Both representations exist but serve different purposes:
- Dashboard: "Mission Auth System — implementation phase — 4/7 tasks done" → from DB
- Agent starting work: reads
docs/PRD-auth-system.md→ from disk - Version history of PRD changes:
git log docs/PRD-auth-system.md→ from git
RBAC & Filesystem Security
Gateway-enforced RBAC — the gateway process (running as mosaic service user) owns all workspace files. RBAC is enforced at the application layer, not the OS layer.
Rationale:
- Gateway is already the single API surface (Architecture Rule #1) — it mediates all operations
- Docker deployment makes OS-level per-user file ownership impractical
- File/git/shell tools already accept
sandboxDirand scope to it
Sandbox Escape Prevention
The primary risk: an agent with shell tool access running commands like cat /opt/mosaic/.workspaces/other_user/... bypasses gateway RBAC.
v1 mitigations:
- Chroot per session — agent tool processes are chrooted to the workspace directory. Cannot see outside. See "Chroot Agent Sandboxing" section below.
- Strict path validation in ALL file/git/shell tools — defense-in-depth alongside chroot
- Shell tool command auditing — log all commands via OTEL
sandboxDirset to the project workspace on session creation
v2 mitigations (multi-tenant hardening):
- Container-per-session for untrusted multi-tenant deployments
- AppArmor/SELinux profiles restricting the gateway process's file access patterns
Permission Inheritance
Within the gateway's RBAC layer:
- Users can only access workspaces for projects they are members of
- Project membership is defined in DB (project_members table or via organization RBAC)
- File tool operations check:
user.idis member ofproject.idthat owns the target workspace path - Admin users can access all workspaces
Workspace Lifecycle
| Event | Filesystem Action |
|---|---|
| User registration | Create $MOSAIC_ROOT/.workspaces/<user_id>/ |
| Project creation | Create <user_id>/<project_id>/, init git repo, create docs structure |
| Session start | Set sandboxDir to project workspace (no new directory needed — sessions work in the repo) |
| Worktree creation | Agent creates <git_repo>-worktrees/<branch>/ via git tools |
| Session end | GC cleans session artifacts (Valkey keys, logs). Workspace files persist (they're in git). |
| Project deletion | Delete <user_id>/<project_id>/ recursively. GC cleans DB artifacts. |
| User deletion | Delete $MOSAIC_ROOT/.workspaces/<user_id>/ recursively. CASCADE handles DB. |
Tooling
Existing tool sets in apps/gateway/src/agent/tools/:
createFileTools(sandboxDir)— file read/write/list ✓createGitTools(sandboxDir)— git operations ✓createShellTools(sandboxDir)— shell commands ✓createWebTools()— HTTP requests ✓createBrainTools(brain)— DB queries ✓createCoordTools(coord)— task coordination ✓createMemoryTools(memory)— memory/insights ✓
Additional tool sets needed for workspace workflows:
- Gitea/GitHub/GitLab API tools — PR creation, review comments, merge, branch protection
- Woodpecker/CI tools — trigger builds, check status, read logs
- Docker/Portainer tools — container management, deployment
- These are registered as additional
ToolDefinition[]sets, same pattern as existing tools
@mosaic/prdy already provides the PRD wizard tooling — the workspace structure gives it a canonical output location (docs/PRD-<name>.md).
Task Queue & Orchestration
Current State: Two Disconnected Systems
There are currently two parallel systems for task management:
-
@mosaic/coord(file-based) — missions stored asmission.json, tasks inTASKS.md, file locks, session tracking, subprocess spawning. Built for single-machine orchestrator pattern. -
PG tables (
tasks,mission_tasks,missions) — DB-backed CRUD with status, priority, assignee, project/mission FKs. Exposed via REST API and Brain repos.
These are not connected. @mosaic/coord reads/writes files. The DB tables are managed via MissionsController. An agent using coord_mission_status gets file-based data; the dashboard shows DB data.
Vision: @mosaic/queue as the Unified Task Layer
@mosaic/queue becomes the task orchestration service — not just a Valkey queue primitive, but the coordinator between agents, DB, and workspace files:
┌──────────────────────────────────────────────┐
│ @mosaic/queue │
│ (Task Orchestration Service) │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ DB (PG) │ │ Files (workspace)│ │
│ │ - tasks table │ │ - TASKS.md │ │
│ │ - missions │ │ - PRDs │ │
│ │ - mission_tasks│ │ - plans/ │ │
│ │ (source of │ │ (working copies │ │
│ │ truth) │ │ for agents) │ │
│ └────────┬────────┘ └────────┬─────────┘ │
│ │ sync │ │
│ └────────┬───────────┘ │
│ │ │
│ ┌─────────────────┴──────────────────┐ │
│ │ Valkey │ │
│ │ - Task assignment queue │ │
│ │ - Agent claim locks │ │
│ │ - Status pub/sub │ │
│ └────────────────────────────────────┘ │
└──────────────┬───────────────────────────────┘
│
┌──────────┼──────────┐
▼ ▼ ▼
Agent A Agent B Gatekeeper
(primary) (worktree) (PR review)
Agent workflow:
- Mission created → tasks written to PG (
taskstable) AND workspace file (docs/TASKS.md) - Tasks enqueued in Valkey for assignment (
mosaic:queue:project:{projectId}:tasks) - Agent requests next task → queue service dequeues, returns task details from PG
- Agent claims the task → Valkey lock + PG status →
in-progress+ file sync - Agent works in its workspace (repo or worktree)
- Agent completes → updates status via queue service → PG updated + file synced + lock released
- Gateway/orchestrator monitors progress, assigns next based on dependencies
Flatfile fallback: If no PG configured, queue service writes to flatfiles in workspace (JSON task manifests). Preserves the @mosaic/coord file-based pattern for single-machine, no-DB deployments.
What this replaces:
@mosaic/coord's file-only task tracking → unified DB+file via queue service- Direct PG CRUD for task status → routed through queue service for consistency
- Manual task assignment → queue-based distribution with agent claiming
What this preserves:
TASKS.mdfile format — still the agent-readable working copy- Mission structure from
@mosaic/coord— creation, milestones, sessions @mosaic/prdyPRD workflow — writes todocs/, syncs metadata to DB
Note: This is a significant refactor of
@mosaic/coord+@mosaic/queue. Warrants its own dedicated plan alongside the Gatekeeper plan.
Chroot Agent Sandboxing
Agents are chrooted to their workspace directory. Sweet spot between full container isolation (heavy) and path validation only (escape-prone).
How it works:
- Before agent tool execution, the gateway spawns tool processes inside a chroot at
sandboxDir - File/git/shell tools operate inside the chroot — literally cannot see outside the workspace
- The chroot environment needs minimal deps: git, shell utilities, language runtimes
- Node.js
child_process.spawnwith chroot requiresCAP_SYS_CHROOTcapability (not root)
Docker consideration:
- Container itself is already isolated
- Chroot inside Docker provides defense-in-depth: user A's agent can't access user B's workspace within the same container
- Alternative: Linux namespaces (
unshare) for lighter-weight isolation without full chroot env setup
v1 approach:
chrootto workspace directory for all agent tool processes- Gateway process gets
CAP_SYS_CHROOTvia capabilities - Minimal chroot environment provisioned by
WorkspaceServiceon workspace creation
What lives outside the chroot (gateway-only, not agent-accessible):
- Valkey connection
- PG connection
- Other users' workspaces
- Gateway configuration
- OTEL collector endpoint
Spin-Off Plans
The following topics are significant enough to warrant their own dedicated plan documents. Stubs created at:
| Plan | Stub File | Scope |
|---|---|---|
| Gatekeeper Service | docs/plans/gatekeeper-service.md |
PR review/merge agent, quality gates, CI integration, trust boundary design |
| Task Queue Unification | docs/plans/task-queue-unification.md |
@mosaic/queue refactor, @mosaic/coord consolidation, DB+file sync, flatfile fallback |
| Chroot Sandboxing | docs/plans/chroot-sandboxing.md |
Chroot environment provisioning, capability management, Docker integration, namespace alternatives |
Teams Architecture
Concept
Projects can be owned by a user (personal) or a team (multi-member collaboration). Teams have a designated manager who controls membership and project settings. The workspace path, RBAC checks, and agent sandbox resolution all branch on owner type.
DB Schema — New Tables
// teams — group identity
export const teams = pgTable('teams', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
ownerId: text('owner_id')
.notNull()
.references(() => users.id, { onDelete: 'restrict' }),
managerId: text('manager_id')
.notNull()
.references(() => users.id, { onDelete: 'restrict' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
// team_members — membership roster
export const teamMembers = pgTable(
'team_members',
{
id: uuid('id').primaryKey().defaultRandom(),
teamId: uuid('team_id')
.notNull()
.references(() => teams.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
role: text('role', { enum: ['manager', 'member'] })
.notNull()
.default('member'),
invitedBy: text('invited_by').references(() => users.id, { onDelete: 'set null' }),
joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
uniq: uniqueIndex('team_members_team_user_idx').on(t.teamId, t.userId),
}),
);
DB Schema — Modified projects Table
// Add to existing projects table:
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }),
ownerType: text('owner_type', { enum: ['user', 'team'] }).notNull().default('user'),
- Solo project:
teamId = null,ownerType = 'user' - Team project:
teamId = <uuid>,ownerType = 'team'
Workspace Path Resolution
| Owner Type | Path |
|---|---|
| Solo | $MOSAIC_ROOT/.workspaces/users/<user_id>/<project_id>/ |
| Team | $MOSAIC_ROOT/.workspaces/teams/<team_id>/<project_id>/ |
RBAC
| Role | Access |
|---|---|
| Team manager | Full project access, manage members, update settings |
| Team member | Project workspace access, create sessions, read/write files |
| Non-member | No access |
| Platform admin | Cross-team read access (audit/support) |
Access check in gateway middleware:
async function canAccessProject(userId: string, projectId: string): Promise<boolean> {
const project = await brain.projects.findById(projectId);
if (project.ownerType === 'user') return project.userId === userId;
if (project.ownerType === 'team') return brain.teams.isMember(project.teamId, userId);
return false;
}
WorkspaceService Path Resolution
resolvePath(project: Project): string {
const root = process.env['MOSAIC_ROOT'] ?? '/opt/mosaic';
if (project.ownerType === 'team') {
return path.join(root, '.workspaces', 'teams', project.teamId!, project.id);
}
return path.join(root, '.workspaces', 'users', project.userId, project.id);
}
REST Route Specifications
Commands with execution: 'rest' or 'hybrid' map to these gateway endpoints. All require authentication.
| Command | Method | Route | Notes |
|---|---|---|---|
/rename |
PATCH |
/api/conversations/:id |
Body: { name: string } |
/resume |
GET |
/api/conversations |
Returns list; TUI shows picker; socket reconnect is client-side |
/history |
GET |
/api/conversations/:id/messages?limit=N |
N defaults to 50 |
/export |
GET |
/api/conversations/:id/export?format=md|json |
Streams file download |
/gc |
POST |
/api/sessions/gc |
Body: { scope: 'user' | 'system' }. system is admin-only. Returns GCSweepResult |
/preferences show |
GET |
/api/preferences |
Returns merged effective preferences (defaults + user mutations) |
/preferences set |
POST |
/api/preferences |
Body: { key: string, value: unknown }. Rejects immutable keys. |
/preferences reset |
DELETE |
/api/preferences/:key |
Removes user override, reverts to platform default |
/provider (list) |
GET |
/api/providers |
Existing endpoint |
/provider login |
GET |
/api/auth/provider/:name/url |
Returns { url: string, expiresAt: string, pollToken: string } |
/provider logout |
DELETE |
/api/auth/providers/:name/session |
Revokes stored token |
/agent (list) |
GET |
/api/agents |
Existing endpoint (P8-005) |
/mission (status) |
GET |
/api/missions?active=true |
Existing endpoint |
/mission set |
POST |
/api/sessions/:sessionId/mission |
Body: { missionId: string } |
/status |
GET |
/api/sessions/:sessionId/status |
Session metadata; admin sees last GC stats |
/reload |
POST |
/api/admin/reload |
RBAC: admin only |
/provider OAuth Flow (TUI)
A TUI session has no embedded browser. The mechanism follows the same pattern as Pi agent: generate URL, copy to clipboard, poll for completion.
Flow
User types: /provider login anthropic
│
▼
TUI emits: command:execute { command: 'provider', args: 'login anthropic' }
│
▼
Gateway: GET /api/auth/provider/anthropic/url
→ Generates OAuth authorization URL
→ Creates poll token in Valkey: mosaic:auth:poll:<token> TTL=5min
→ Returns { url, expiresAt, pollToken }
│
▼
Gateway emits: command:result { success: true, data: { url, expiresAt } }
│
▼
TUI:
1. Writes URL to clipboard (clipboardy)
2. Shows system message: "⚙ Authorization URL copied to clipboard. Open in browser to authorize Anthropic."
3. Starts polling: GET /api/auth/provider/anthropic/status?token=<pollToken>
4. Poll interval: 3s, max timeout: 5min
│
▼
User opens browser, completes OAuth
│
▼
Gateway receives callback, stores token, marks poll token as completed
│
▼
TUI poll returns: { status: 'completed', provider: 'anthropic' }
TUI shows: "⚙ Anthropic connected successfully."
TUI requests fresh commands:manifest (reflects new provider availability)
Poll Status Endpoint
GET /api/auth/provider/:name/status?token=<pollToken> → { status: 'pending' | 'completed' | 'failed' | 'expired' }
Implementation Notes
- Gateway stores poll state in Valkey:
mosaic:auth:poll:<pollToken>with 5-min TTL clipboardyused for clipboard write in TUI (add as dep to@mosaic/cliif not already present)- On success, gateway emits a fresh
commands:manifestvia socket (reflects provider now connected)
Preferences mutable Column Migration
Add to packages/db/src/schema.ts in the preferences table definition:
mutable: boolean('mutable').notNull().default(true),
Generate and apply:
pnpm --filter @mosaic/db db:generate # generates migration SQL
pnpm --filter @mosaic/db db:migrate # applies to PG
Platform enforcement keys (seeded with mutable = false by gateway PreferencesService.onModuleInit()):
| Key | Category | Reason |
|---|---|---|
limits.maxThinkingLevel |
limits | Admin-controlled ceiling; role-dependent |
limits.rateLimit |
limits | Admin-controlled rate cap; role-dependent |
safety.contentFiltering |
safety | Cannot be disabled by users |
/preferences set calls PreferencesService.set() which reads the mutable flag and rejects immutable keys with:
⚙ Cannot override "limits.maxThinkingLevel" — this is a platform enforcement. Contact your admin.
Test Strategy
Test Files Per Task
| Task | Test File(s) | Test Types |
|---|---|---|
| P8-007 (DB) | packages/db/src/__tests__/teams.test.ts |
Schema validation, FK constraint checks |
| P8-008 (Types) | — | Compile-time only; covered by typecheck gate |
| P8-009 (TUI parsing) | packages/cli/src/__tests__/parse-command.test.ts |
Unit: parseSlashCommand, alias resolution, unknown commands, skill:name syntax |
| P8-010 (Gateway registry) | apps/gateway/src/commands/__tests__/command-registry.service.spec.ts |
Unit: manifest build, RBAC filter; Integration: socket command:execute round-trip |
| P8-011 (Preferences) | apps/gateway/src/preferences/__tests__/preferences.service.spec.ts |
Unit: merge logic, mutable enforcement; Integration: /preferences REST + /system Valkey |
| P8-012 (Commands P4) | apps/gateway/src/commands/__tests__/command-executor.service.spec.ts |
Integration: /agent, /mission, /provider URL gen + poll state |
| P8-013 (Hot Reload) | apps/gateway/src/reload/__tests__/reload.service.spec.ts |
Integration: plugin unload/reload cycle, manifest diff, system:reload broadcast |
| P8-014 (Session GC) | apps/gateway/src/gc/__tests__/session-gc.service.spec.ts |
Unit: collect, sweepOrphans, fullCollect — all three tiers |
| P8-015 (Workspaces) | apps/gateway/src/workspace/__tests__/workspace.service.spec.ts |
Unit: path resolution (solo vs team); Integration: project creation sequence, cleanup |
| P8-016 (Tool hardening) | apps/gateway/src/agent/tools/__tests__/path-validation.spec.ts |
Unit: sandbox escape attempt rejection (path traversal, symlink) |
| P8-017 (Autocomplete) | packages/cli/src/__tests__/autocomplete.test.ts |
Component: fuzzy match, keyboard nav, arg hints |
Key Test Cases Per Phase
P8-009 (TUI parsing):
parseSlashCommand('/help')→{ command: 'help', args: null }parseSlashCommand('/skill:brave-search query')→{ command: 'skill:brave-search', args: 'query' }parseSlashCommand('not a command')→null- Alias resolution:
/m claude-4→ resolves to/model - Unknown command: shows inline error, does NOT emit to socket
P8-010 (gateway registry):
commands:manifestpushed on socket connect (alongsidesession:info)- Admin command not in manifest for non-admin user
command:executefor unknown command →command:result { success: false }
P8-011 (preferences):
- Platform default → user override → enforcement re-applied (enforcements always win)
/systemcondensation: mock LLM call, verify condensed output written to Valkey- TTL renewal: verify
EXPIREcalled on each agent turn
P8-014 (Session GC):
collect(sessionId): mock Valkey + DB, verify correct keys deleted and logs demotedsweepOrphans(): seed orphaned Valkey keys, verify detection and deletionfullCollect()(cold start): seed stale keys across all namespaces, verify all cleared
P8-015 (Workspaces):
- Solo project: workspace created at
users/<user_id>/<project_id>/ - Team project: workspace created at
teams/<team_id>/<project_id>/ - Non-member access attempt:
canAccessProject()returns false repoUrlprovided: workspace created viagit clone, notgit init
Phase Execution Order
This is the authoritative dependency and parallelism plan for implementation.
Dependency Graph
P8-007 (DB migrations) ──────────────────────────────────► P8-015 (Workspaces)
P8-008 (Types) ──────────► P8-009 (TUI local cmds)
└────────► P8-010 (Gateway registry) ───► P8-011 (Preferences)
├──► P8-012 (Commands P4)
├──► P8-013 (Hot Reload)
└──► P8-014 (Session GC)
P8-008 + P8-010 ─────────────────────────────────────────► P8-017 (Autocomplete)
P8-007 ─────────────────────────────────────────────────► P8-016 (Tool hardening) [independent]
(all above) ─────────────────────────────────────────────► P8-019 (Verify)
Wave Execution Plan
| Wave | Tasks | Parallelism |
|---|---|---|
| Wave 1 | P8-007 (DB migrations) + P8-008 (Types) | 2 workers |
| Wave 2 | P8-009 (TUI local cmds) + P8-016 (Tool hardening) | 2 workers |
| Wave 3 | P8-010 (Gateway command registry) | 1 worker — gating wave |
| Wave 4 | P8-011 (Preferences) + P8-012 (Commands P4) | 2 workers |
| Wave 5 | P8-013 (Hot Reload) + P8-014 (Session GC) | 2 workers |
| Wave 6 | P8-015 (Workspaces + Teams) | 1 worker |
| Wave 7 | P8-017 (Autocomplete) | 1 worker |
| Wave 8 | P8-019 (Verify) | 1 worker |
P8-018 (spin-off plan stubs) is documentation-only — completed during plan preparation, not in execution waves.