Compare commits

..

15 Commits

Author SHA1 Message Date
811a32eb61 fix(web): fix lint/prettier and TypeScript errors in proxy route
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Remove unnecessary null-coalesce on request.nextUrl.search
- Fix body typing: use null instead of undefined for no-body requests
- Use conditional spread to satisfy fetch() overload types
- Auto-fix prettier formatting across all changed files
2026-03-08 11:54:11 -05:00
23036cb1dd fix(web): route Mission Control API calls through orchestrator proxy
Some checks failed
ci/woodpecker/push/ci Pipeline failed
The Mission Control components were calling /api/mission-control/* which
routes to mosaic-api.woltje.com (the main API) — those routes don't exist
there, causing 404s.

These routes live on the orchestrator service and must go through the
Next.js server-side proxy (which injects ORCHESTRATOR_API_KEY):

Changes:
- Add catch-all proxy route at /api/orchestrator/[...path]/route.ts
  Forwards any GET/POST/PATCH/PUT/DELETE to ORCHESTRATOR_URL/<path>
  Replaces the need for per-endpoint proxy files for new routes.
- Update all Mission Control components to call
  /api/orchestrator/api/mission-control/* instead of /api/mission-control/*
- Update test expectations to match new paths
2026-03-08 11:45:31 -05:00
523662656e Merge pull request 'fix(deploy): use consistent network alias for openbrain-brain-internal' (#746) from fix/compose-network-alias-consistency into main 2026-03-08 16:35:32 +00:00
27120ac3f2 fix(deploy): use consistent network alias for openbrain-brain-internal
All checks were successful
ci/woodpecker/push/ci Infra-only: compose YAML fix, no app code
Service definitions were using 'openbrain_brain-internal' (underscore) but the
networks block defines the alias as 'openbrain-brain-internal' (hyphen), with
name: openbrain_brain-internal pointing to the actual Docker network.

This caused 'undefined network' errors on every Portainer deploy for
orchestrator and synapse services.

Fixed: all service network references now use 'openbrain-brain-internal'.
2026-03-08 11:34:38 -05:00
ad9921107c fix(deploy): add DATABASE_URL and openbrain network to orchestrator + synapse (#745)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-08 15:51:22 +00:00
3c288f9849 fix(web): add ReactQueryProvider to root layout for Mission Control (#744)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-08 15:50:40 +00:00
51d6302401 Merge pull request 'style(web): fix prettier formatting in AuditLogDrawer' (#743) from fix/audit-drawer-format into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-08 01:56:03 +00:00
cf490510bf style(web): fix prettier formatting in AuditLogDrawer
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 19:54:08 -06:00
3d91334df7 Merge pull request 'fix(mission-control): increase rate limit for events/recent, add error handling' (#742) from fix/mission-control-ratelimit into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-08 00:44:31 +00:00
e80b624ca6 fix(mission-control): increase rate limit for events/recent, add error handling
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-07 18:42:50 -06:00
65536fcb75 Merge pull request 'fix(orchestrator): add missing module import for OrchestratorApiKeyGuard' (#741) from fix/orchestrator-guard-import into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 23:36:55 +00:00
53915dc621 fix(orchestrator): add missing module import for OrchestratorApiKeyGuard
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 17:35:57 -06:00
398ee06920 Merge pull request 'chore: release v0.0.23 — Mission Control Dashboard' (#740) from chore/ms23-v0.0.23-release into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 23:01:19 +00:00
2182717f59 chore: release v0.0.23 — Mission Control Dashboard
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 17:00:15 -06:00
fe55363f38 Merge pull request 'chore: MS23-P4-001 QA gate — lint/typecheck/test all green' (#739) from chore/ms23-p4-qa into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 22:57:06 +00:00
20 changed files with 305 additions and 59 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaic/orchestrator",
"version": "0.0.20",
"version": "0.0.23",
"private": true,
"scripts": {
"build": "nest build",

View File

@@ -146,7 +146,7 @@ export class AgentsController {
* Return recent orchestrator events for non-streaming consumers.
*/
@Get("events/recent")
@Throttle({ status: { limit: 200, ttl: 60000 } })
@Throttle({ default: { limit: 1000, ttl: 60000 } })
getRecentEvents(@Query("limit") limit?: string): {
events: ReturnType<AgentEventsService["getRecentEvents"]>;
} {

View File

@@ -4,6 +4,6 @@ import { AuthGuard } from "./guards/auth.guard";
@Module({
providers: [OrchestratorApiKeyGuard, AuthGuard],
exports: [AuthGuard],
exports: [OrchestratorApiKeyGuard, AuthGuard],
})
export class AuthModule {}

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaic/web",
"version": "0.0.20",
"version": "0.0.23",
"private": true,
"scripts": {
"build": "next build",

View File

@@ -0,0 +1,93 @@
import { type NextRequest, NextResponse } from "next/server";
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
function getOrchestratorUrl(): string {
return (
process.env.ORCHESTRATOR_URL ??
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
process.env.NEXT_PUBLIC_API_URL ??
DEFAULT_ORCHESTRATOR_URL
);
}
/**
* Generic catch-all proxy for orchestrator API routes.
*
* Forwards any request to /api/orchestrator/<path> → ORCHESTRATOR_URL/<path>
* with the ORCHESTRATOR_API_KEY injected server-side so it never reaches the browser.
*
* Supports GET, POST, PATCH, DELETE, PUT.
*
* Example:
* GET /api/orchestrator/mission-control/sessions
* → GET ORCHESTRATOR_URL/api/mission-control/sessions
* POST /api/orchestrator/mission-control/sessions/abc/kill
* → POST ORCHESTRATOR_URL/api/mission-control/sessions/abc/kill
*/
async function proxyToOrchestrator(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
): Promise<NextResponse> {
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
if (!orchestratorApiKey) {
return NextResponse.json(
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
{ status: 503 }
);
}
const { path } = await context.params;
const upstreamPath = `/${path.join("/")}`;
const search = request.nextUrl.search;
const upstreamUrl = `${getOrchestratorUrl()}${upstreamPath}${search}`;
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 30_000);
try {
const headers: Record<string, string> = {
"X-API-Key": orchestratorApiKey,
};
const contentType = request.headers.get("Content-Type");
if (contentType) {
headers["Content-Type"] = contentType;
}
const hasBody = request.method !== "GET" && request.method !== "HEAD";
const body = hasBody ? await request.text() : null;
const upstream = await fetch(upstreamUrl, {
method: request.method,
headers,
...(body !== null ? { body } : {}),
cache: "no-store",
signal: controller.signal,
});
const responseText = await upstream.text();
return new NextResponse(responseText, {
status: upstream.status,
headers: {
"Content-Type": upstream.headers.get("Content-Type") ?? "application/json",
},
});
} catch (error) {
const message =
error instanceof Error && error.name === "AbortError"
? "Orchestrator request timed out."
: "Unable to reach orchestrator.";
return NextResponse.json({ error: message }, { status: 502 });
} finally {
clearTimeout(timeout);
}
}
export const GET = proxyToOrchestrator;
export const POST = proxyToOrchestrator;
export const PATCH = proxyToOrchestrator;
export const PUT = proxyToOrchestrator;
export const DELETE = proxyToOrchestrator;

View File

@@ -4,6 +4,7 @@ import { Outfit, Fira_Code } from "next/font/google";
import { AuthProvider } from "@/lib/auth/auth-context";
import { ErrorBoundary } from "@/components/error-boundary";
import { ThemeProvider } from "@/providers/ThemeProvider";
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
import "./globals.css";
export const dynamic = "force-dynamic";
@@ -56,9 +57,11 @@ export default function RootLayout({ children }: { children: ReactNode }): React
</head>
<body>
<ThemeProvider>
<ErrorBoundary>
<AuthProvider>{children}</AuthProvider>
</ErrorBoundary>
<ReactQueryProvider>
<ErrorBoundary>
<AuthProvider>{children}</AuthProvider>
</ErrorBoundary>
</ReactQueryProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -44,6 +44,25 @@ interface AuditLogResponse {
total: number;
page: number;
pages: number;
notice?: string;
}
function createEmptyAuditLogResponse(page: number, notice?: string): AuditLogResponse {
return {
items: [],
total: 0,
page,
pages: 0,
...(notice !== undefined ? { notice } : {}),
};
}
function isRateLimitError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return /429|rate limit|too many requests/i.test(error.message);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -138,7 +157,17 @@ async function fetchAuditLog(
params.set("sessionId", normalizedSessionId);
}
return apiGet<AuditLogResponse>(`/api/mission-control/audit-log?${params.toString()}`);
try {
return await apiGet<AuditLogResponse>(
`/api/orchestrator/api/mission-control/audit-log?${params.toString()}`
);
} catch (error) {
if (isRateLimitError(error)) {
return createEmptyAuditLogResponse(page, "Rate limited - retrying...");
}
return createEmptyAuditLogResponse(page);
}
}
export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): React.JSX.Element {
@@ -180,11 +209,10 @@ export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): Rea
const totalItems = auditLogQuery.data?.total ?? 0;
const totalPages = auditLogQuery.data?.pages ?? 0;
const items = auditLogQuery.data?.items ?? [];
const notice = auditLogQuery.data?.notice;
const canGoPrevious = page > 1;
const canGoNext = totalPages > 0 && page < totalPages;
const errorMessage =
auditLogQuery.error instanceof Error ? auditLogQuery.error.message : "Failed to load audit log";
return (
<Sheet open={open} onOpenChange={setOpen}>
@@ -237,10 +265,13 @@ export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): Rea
Loading audit log...
</td>
</tr>
) : auditLogQuery.error ? (
) : notice ? (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-sm text-red-500">
{errorMessage}
<td
colSpan={5}
className="px-3 py-6 text-center text-sm text-muted-foreground"
>
{notice}
</td>
</tr>
) : items.length === 0 ? (

View File

@@ -59,9 +59,12 @@ describe("BargeInInput", (): void => {
await user.click(screen.getByRole("button", { name: "Send" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/inject", {
content: "execute plan",
});
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/session-1/inject",
{
content: "execute plan",
}
);
});
expect(onSent).toHaveBeenCalledTimes(1);
@@ -83,12 +86,18 @@ describe("BargeInInput", (): void => {
const calls = mockApiPost.mock.calls as [string, unknown?][];
expect(calls[0]).toEqual(["/api/mission-control/sessions/session-2/pause", undefined]);
expect(calls[0]).toEqual([
"/api/orchestrator/api/mission-control/sessions/session-2/pause",
undefined,
]);
expect(calls[1]).toEqual([
"/api/mission-control/sessions/session-2/inject",
"/api/orchestrator/api/mission-control/sessions/session-2/inject",
{ content: "hello world" },
]);
expect(calls[2]).toEqual(["/api/mission-control/sessions/session-2/resume", undefined]);
expect(calls[2]).toEqual([
"/api/orchestrator/api/mission-control/sessions/session-2/resume",
undefined,
]);
});
it("submits with Enter and does not submit on Shift+Enter", async (): Promise<void> => {
@@ -105,9 +114,12 @@ describe("BargeInInput", (): void => {
fireEvent.keyDown(textarea, { key: "Enter", code: "Enter", shiftKey: false });
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-3/inject", {
content: "first",
});
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/session-3/inject",
{
content: "first",
}
);
});
});

View File

@@ -39,7 +39,7 @@ export function BargeInInput({ sessionId, onSent }: BargeInInputProps): React.JS
}
const encodedSessionId = encodeURIComponent(sessionId);
const baseEndpoint = `/api/mission-control/sessions/${encodedSessionId}`;
const baseEndpoint = `/api/orchestrator/api/mission-control/sessions/${encodedSessionId}`;
let didPause = false;
let didInject = false;

View File

@@ -177,9 +177,12 @@ describe("GlobalAgentRoster", (): void => {
fireEvent.click(screen.getByRole("button", { name: "Kill session killme12" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", {
force: false,
});
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/killme123456/kill",
{
force: false,
}
);
});
});

View File

@@ -83,7 +83,7 @@ function groupByProvider(sessions: MissionControlSession[]): ProviderSessionGrou
async function fetchSessions(): Promise<MissionControlSession[]> {
const payload = await apiGet<MissionControlSession[] | SessionsPayload>(
"/api/mission-control/sessions"
"/api/orchestrator/api/mission-control/sessions"
);
return Array.isArray(payload) ? payload : payload.sessions;
}
@@ -118,9 +118,12 @@ export function GlobalAgentRoster({
const killMutation = useMutation({
mutationFn: async (sessionId: string): Promise<string> => {
await apiPost<{ message: string }>(`/api/mission-control/sessions/${sessionId}/kill`, {
force: false,
});
await apiPost<{ message: string }>(
`/api/orchestrator/api/mission-control/sessions/${sessionId}/kill`,
{
force: false,
}
);
return sessionId;
},
onSuccess: (): void => {

View File

@@ -112,14 +112,20 @@ describe("KillAllDialog", (): void => {
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-1/kill", {
force: true,
});
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/internal-1/kill",
{
force: true,
}
);
});
expect(mockApiPost).not.toHaveBeenCalledWith("/api/mission-control/sessions/external-1/kill", {
force: true,
});
expect(mockApiPost).not.toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/external-1/kill",
{
force: true,
}
);
expect(onComplete).toHaveBeenCalledTimes(1);
});
@@ -141,12 +147,18 @@ describe("KillAllDialog", (): void => {
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-2/kill", {
force: true,
});
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/external-2/kill", {
force: true,
});
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/internal-2/kill",
{
force: true,
}
);
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/external-2/kill",
{
force: true,
}
);
});
});

View File

@@ -96,9 +96,12 @@ export function KillAllDialog({ sessions, onComplete }: KillAllDialogProps): Rea
const killRequests = targetSessions.map(async (session) => {
try {
await apiPost<{ message: string }>(`/api/mission-control/sessions/${session.id}/kill`, {
force: true,
});
await apiPost<{ message: string }>(
`/api/orchestrator/api/mission-control/sessions/${session.id}/kill`,
{
force: true,
}
);
return true;
} catch {
return false;

View File

@@ -89,7 +89,7 @@ describe("PanelControls", (): void => {
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith(
"/api/mission-control/sessions/session%20with%20space/pause",
"/api/orchestrator/api/mission-control/sessions/session%20with%20space/pause",
undefined
);
});
@@ -114,9 +114,12 @@ describe("PanelControls", (): void => {
await user.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-4/kill", {
force: false,
});
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/session-4/kill",
{
force: false,
}
);
});
expect(onStatusChange).toHaveBeenCalledWith("killed");
@@ -137,9 +140,12 @@ describe("PanelControls", (): void => {
await user.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-5/kill", {
force: true,
});
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/session-5/kill",
{
force: true,
}
);
});
expect(onStatusChange).toHaveBeenCalledWith("killed");

View File

@@ -50,23 +50,23 @@ export function PanelControls({
switch (action) {
case "pause":
await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause`
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause`
);
return { nextStatus: "paused" };
case "resume":
await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume`
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume`
);
return { nextStatus: "active" };
case "graceful-kill":
await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
{ force: false }
);
return { nextStatus: "killed" };
case "force-kill":
await apiPost<{ message: string }>(
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
{ force: true }
);
return { nextStatus: "killed" };

View File

@@ -0,0 +1,28 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
interface ReactQueryProviderProps {
children: ReactNode;
}
export function ReactQueryProvider({ children }: ReactQueryProviderProps): React.JSX.Element {
// Create a stable QueryClient per component mount (one per app session)
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// Don't refetch on window focus in a dashboard context
refetchOnWindowFocus: false,
// Stale time of 30s — short enough for live data, avoids hammering
staleTime: 30_000,
retry: 1,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -316,6 +316,8 @@ services:
SANDBOX_ENABLED: "true"
# API key for authenticating requests from the web proxy
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
# Prisma database connection (uses the shared openbrain postgres)
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@openbrain_brain-db:5432/${POSTGRES_DB:-mosaic}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- orchestrator_workspace:/workspace
@@ -331,6 +333,7 @@ services:
start_period: 40s
networks:
- internal
- openbrain-brain-internal
cap_drop:
- ALL
cap_add:
@@ -403,6 +406,7 @@ services:
networks:
- internal
- traefik-public
- openbrain-brain-internal
deploy:
restart_policy:
condition: on-failure

View File

@@ -62,6 +62,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Updated Makefile with Traefik deployment shortcuts
- Enhanced docker-compose.override.yml.example with Traefik examples
## [0.0.23] - 2026-03-07
### Added
- **Mission Control Dashboard** — real-time agent orchestration UI at `/mission-control`
- Live SSE message streams per agent (`OrchestratorPanel`)
- Barge-in input with optional pause-before-send
- Pause / Resume / Graceful Kill / Force Kill controls per agent panel
- Global agent roster sidebar with tree view and per-agent kill
- KillAllDialog with scope selector (requires typing `KILL ALL` to confirm)
- AuditLogDrawer with paginated operator action history
- Responsive panel grid: up to 6 panels, add/remove, full-screen expand
- **Agent Provider Interface** — extensible `IAgentProvider` plugin system
- `InternalAgentProvider` wrapping existing orchestrator services
- `AgentProviderRegistry` aggregating sessions across providers
- `AgentProviderConfig` CRUD API (`/api/agent-providers`)
- Mission Control proxy API (`/api/mission-control/*`) with SSE proxying and audit log
- **OpenClaw Provider Adapter** — connect external OpenClaw instances
- `OpenClawProvider` implementing `IAgentProvider` against OpenClaw REST API
- Dedicated `OpenClawSseBridge` with retry logic (5 retries, 2s backoff)
- Provider config UI in Settings for registering OpenClaw gateways
- Tokens encrypted at rest via `EncryptionService` (AES-256-GCM)
- **OperatorAuditLog** — every inject/pause/resume/kill persisted to DB
### Changed
- Orchestrator app: extended with `AgentsModule` exports for provider registry
- Settings navigation: added "Agent Providers" section
### Fixed
- Flaky web tests: async query timing in Kanban and OnboardingWizard tests
## [0.0.1] - 2026-01-28
### Added
@@ -79,5 +112,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Documentation structure (Bookstack-compatible hierarchy)
- Development workflow and coding standards
[Unreleased]: https://git.mosaicstack.dev/mosaic/stack/compare/v0.0.1...HEAD
[Unreleased]: https://git.mosaicstack.dev/mosaic/stack/compare/v0.0.23...HEAD
[0.0.23]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.23
[0.0.1]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.1

View File

@@ -1,6 +1,6 @@
# Mosaic Stack Roadmap
**Last Updated:** 2026-01-29
**Last Updated:** 2026-03-07
**Authoritative Source:** [Issues & Milestones](https://git.mosaicstack.dev/mosaic/stack/issues)
## Versioning Policy
@@ -12,6 +12,20 @@
| `0.x.y` | Pre-stable iteration, API may change with notice |
| `1.0.0` | Stable release, public API contract |
## Release Track (Current)
### ✅ v0.0.23 — Mission Control Dashboard (Complete)
- Mission Control dashboard shipped at `/mission-control`
- Agent provider plugin system and Mission Control proxy API shipped
- OpenClaw provider adapter shipped with encrypted token storage
- Operator audit logging persisted for inject/pause/resume/kill actions
### 📋 v0.0.24 — Placeholder
- Scope TBD (to be defined after v0.0.23 production deployment)
- Initial release notes and roadmap breakdown pending
---
## Milestone Overview

View File

@@ -1,6 +1,6 @@
{
"name": "mosaic-stack",
"version": "0.0.20",
"version": "0.0.23",
"private": true,
"type": "module",
"packageManager": "pnpm@10.19.0",