+ Colaboração humano+agente sobre um relay Nostr auto-hospedado (auth NIP-42,
+ outbox/inbox idempotente). A chave Nostr nunca autoriza uma ação no
+ OmniRoute. Configurado aqui, no painel único.
+
+ Ciclos agênticos report-only: o Loop propõe, o Policy Engine decide e
+ nenhum efeito externo roda sem aprovação humana. Serve ao painel e a qualquer harness via{" "}
+ /api/loop.
+
+ );
+}
diff --git a/src/app/api/browser/check/route.ts b/src/app/api/browser/check/route.ts
new file mode 100644
index 00000000000..71715569f99
--- /dev/null
+++ b/src/app/api/browser/check/route.ts
@@ -0,0 +1,64 @@
+/**
+ * POST /api/browser/check — avalia uma ação de navegador pela política determinística.
+ * Body: { action: BrowserAction, allowedDomains?: string[] }.
+ *
+ * Autenticado (management) e gated por BROWSER_USE_ENABLED. Se allowedDomains não vier no corpo,
+ * usa o override persistido no painel (key_value namespace 'browser'). Efeito externo → aprovação
+ * humana; efeito originado na página → deny (prompt injection não escala). Não executa nada.
+ */
+import { NextRequest, NextResponse } from "next/server";
+
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { getDbInstance } from "@/lib/db/core";
+import { traceSync } from "@/lib/otel";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+import {
+ decideBrowserAction,
+ type BrowserAction,
+} from "@omniroute/open-sse/browser-guard/index.ts";
+
+function storedAllowlist(): string[] {
+ try {
+ const row = getDbInstance()
+ .prepare(
+ "SELECT value FROM key_value WHERE namespace = 'browser' AND key = 'allowed_domains'"
+ )
+ .get() as { value: string } | undefined;
+ return row?.value ? (JSON.parse(row.value) as string[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+export async function POST(req: NextRequest): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ if (!isFeatureFlagEnabled("BROWSER_USE_ENABLED")) {
+ return NextResponse.json(
+ { error: "Browser Use is disabled. Enable BROWSER_USE_ENABLED in the OmniRoute panel." },
+ { status: 404 }
+ );
+ }
+
+ const body = (await req.json().catch(() => ({}))) as {
+ action?: Partial;
+ allowedDomains?: unknown;
+ };
+ const a = body.action;
+ if (!a || typeof a.kind !== "string" || (a.origin !== "user" && a.origin !== "page")) {
+ return NextResponse.json(
+ { error: "action {kind, origin: 'user'|'page', url?} is required" },
+ { status: 400 }
+ );
+ }
+ const allowedDomains = Array.isArray(body.allowedDomains)
+ ? (body.allowedDomains.filter((d) => typeof d === "string") as string[])
+ : storedAllowlist();
+
+ const verdict = traceSync(
+ "browser.check",
+ { route: "/api/browser/check", "http.method": "POST" },
+ () => decideBrowserAction(a as BrowserAction, { enabled: true, allowedDomains })
+ );
+ return NextResponse.json({ verdict, allowedDomains });
+}
diff --git a/src/app/api/buzz/flush/route.ts b/src/app/api/buzz/flush/route.ts
new file mode 100644
index 00000000000..d691398b32d
--- /dev/null
+++ b/src/app/api/buzz/flush/route.ts
@@ -0,0 +1,31 @@
+/**
+ * POST /api/buzz/flush — publica as entradas pendentes do outbox no relay real (idempotente).
+ * Gated por BUZZ_HUB_ENABLED: com a flag OFF retorna { skipped: true } sem conectar.
+ *
+ * Serve ao painel único (botão "Publicar pendentes") e a qualquer harness/automação.
+ */
+import { NextRequest, NextResponse } from "next/server";
+
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { flushBuzzOutbox } from "@/lib/buzzService";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+
+export async function POST(req: NextRequest): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ if (!isFeatureFlagEnabled("BUZZ_HUB_ENABLED")) {
+ return NextResponse.json(
+ { published: 0, failed: 0, skipped: true, reason: "BUZZ_HUB_ENABLED is off" },
+ { status: 200 }
+ );
+ }
+ try {
+ const result = await flushBuzzOutbox();
+ return NextResponse.json(result);
+ } catch (e) {
+ return NextResponse.json(
+ { error: e instanceof Error ? e.message : "flush failed" },
+ { status: 502 }
+ );
+ }
+}
diff --git a/src/app/api/buzz/route.ts b/src/app/api/buzz/route.ts
new file mode 100644
index 00000000000..e0c83e538c6
--- /dev/null
+++ b/src/app/api/buzz/route.ts
@@ -0,0 +1,44 @@
+/**
+ * GET /api/buzz — estado do Buzz Hub para o painel único: flag, relay, pubkey do agente e
+ * contagens do outbox/inbox. NUNCA expõe a chave secreta. Leitura local (não conecta ao relay).
+ *
+ * Autenticado (management). Ao contrário do Loop, o status É legível mesmo com a flag OFF —
+ * para o painel poder mostrar "desligado" e orientar a ativação. Nada conecta enquanto OFF.
+ */
+import { NextRequest, NextResponse } from "next/server";
+
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { getBuzzStatus, setBuzzRelayUrl } from "@/lib/buzzService";
+
+export async function GET(req: NextRequest): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ return NextResponse.json(getBuzzStatus());
+}
+
+/**
+ * PUT /api/buzz — define a URL do relay pelo painel único. Body: { relayUrl: string }.
+ *
+ * INTENCIONALMENTE não é gated por BUZZ_HUB_ENABLED (diferente de /flush): é configuração
+ * (não-secreta, admin-only) que o operador ajusta ANTES de ligar a flag. Nada conecta aqui —
+ * a conexão só ocorre no flush, que é gated. O alvo é local por design (default ws://localhost:3000);
+ * apontar para um host interno exige management-auth (o admin já controla o host).
+ */
+export async function PUT(req: NextRequest): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ const body = (await req.json().catch(() => ({}))) as { relayUrl?: unknown };
+ if (typeof body.relayUrl !== "string") {
+ return NextResponse.json({ error: "relayUrl (string) is required" }, { status: 400 });
+ }
+ const trimmed = body.relayUrl.trim();
+ // Aceita ws:// ou wss:// (ou vazio para limpar o override). Evita URLs inválidas no relay.
+ if (trimmed && !/^wss?:\/\//i.test(trimmed)) {
+ return NextResponse.json(
+ { error: "relayUrl must start with ws:// or wss://" },
+ { status: 400 }
+ );
+ }
+ setBuzzRelayUrl(trimmed);
+ return NextResponse.json(getBuzzStatus());
+}
diff --git a/src/app/api/loop/[id]/advance/route.ts b/src/app/api/loop/[id]/advance/route.ts
new file mode 100644
index 00000000000..7e36ba8086c
--- /dev/null
+++ b/src/app/api/loop/[id]/advance/route.ts
@@ -0,0 +1,34 @@
+/**
+ * POST /api/loop/[id]/advance — avança UM passo do run (report-only, persistente).
+ * Body opcional: { consumed?: {tokens?,wallClockMs?,attempts?} }.
+ * O gate segura efeitos externos em awaiting_approval; nada é executado aqui.
+ */
+import { NextRequest, NextResponse } from "next/server";
+
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { advanceRun } from "@/lib/loopRunner";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ if (!isFeatureFlagEnabled("LOOP_ENGINE_ENABLED")) {
+ return NextResponse.json({ error: "Loop Engine is disabled." }, { status: 404 });
+ }
+ const { id } = await params;
+ const body = (await req.json().catch(() => ({}))) as {
+ consumed?: { tokens?: number; wallClockMs?: number; attempts?: number };
+ };
+ try {
+ const result = advanceRun(id, { consumed: body.consumed, policy: { reportOnly: true } });
+ return NextResponse.json(result);
+ } catch (e) {
+ return NextResponse.json(
+ { error: e instanceof Error ? e.message : "advance failed" },
+ { status: 404 }
+ );
+ }
+}
diff --git a/src/app/api/loop/[id]/approve/route.ts b/src/app/api/loop/[id]/approve/route.ts
new file mode 100644
index 00000000000..dfbf6da74eb
--- /dev/null
+++ b/src/app/api/loop/[id]/approve/route.ts
@@ -0,0 +1,39 @@
+/**
+ * POST /api/loop/[id]/approve — aprova ou rejeita uma etapa aguardando aprovação humana.
+ * Body: { stepId: string, decision: "approve" | "reject" }.
+ * Aprovar libera o run; rejeitar marca a etapa. Efeito externo real (quando aprovado) é
+ * executado pelos conectores do OmniRoute, fora daqui.
+ */
+import { NextRequest, NextResponse } from "next/server";
+
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { approveStep, rejectStep } from "@/lib/loopRunner";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ if (!isFeatureFlagEnabled("LOOP_ENGINE_ENABLED")) {
+ return NextResponse.json({ error: "Loop Engine is disabled." }, { status: 404 });
+ }
+ const { id } = await params;
+ const body = (await req.json().catch(() => ({}))) as {
+ stepId?: unknown;
+ decision?: unknown;
+ };
+ const stepId = typeof body.stepId === "string" ? body.stepId : "";
+ const decision = body.decision === "reject" ? "reject" : "approve";
+ if (!stepId) return NextResponse.json({ error: "stepId is required" }, { status: 400 });
+ try {
+ const run = decision === "reject" ? rejectStep(id, stepId) : approveStep(id, stepId);
+ return NextResponse.json({ run, decision });
+ } catch (e) {
+ return NextResponse.json(
+ { error: e instanceof Error ? e.message : "approve failed" },
+ { status: 404 }
+ );
+ }
+}
diff --git a/src/app/api/loop/[id]/route.ts b/src/app/api/loop/[id]/route.ts
new file mode 100644
index 00000000000..3ab8f59bfcb
--- /dev/null
+++ b/src/app/api/loop/[id]/route.ts
@@ -0,0 +1,24 @@
+/**
+ * GET /api/loop/[id] — retorna um run do Loop Engine (com suas etapas).
+ * Autenticado + gated por LOOP_ENGINE_ENABLED.
+ */
+import { NextRequest, NextResponse } from "next/server";
+
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { getLoopRun } from "@/lib/loopRunner";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+
+export async function GET(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ if (!isFeatureFlagEnabled("LOOP_ENGINE_ENABLED")) {
+ return NextResponse.json({ error: "Loop Engine is disabled." }, { status: 404 });
+ }
+ const { id } = await params;
+ const run = getLoopRun(id);
+ if (!run) return NextResponse.json({ error: "run not found" }, { status: 404 });
+ return NextResponse.json({ run });
+}
diff --git a/src/app/api/loop/[id]/stream/route.ts b/src/app/api/loop/[id]/stream/route.ts
new file mode 100644
index 00000000000..ae03a68f9d5
--- /dev/null
+++ b/src/app/api/loop/[id]/stream/route.ts
@@ -0,0 +1,69 @@
+/**
+ * GET /api/loop/[id]/stream — transmite o estado de um run do Loop como eventos AG-UI (SSE).
+ *
+ * Autenticado (management) e gated por LOOP_ENGINE_ENABLED. Emite RUN_STARTED, um bloco de texto
+ * por etapa (START/CONTENT/END), um STATE_SNAPSHOT (fase/status) e RUN_FINISHED — fluxo AG-UI válido
+ * (ordenado por seq), consumível por um EventSource no Agent Console. Report-only (só leitura).
+ */
+import { NextRequest, NextResponse } from "next/server";
+
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { getLoopRun } from "@/lib/loopRunner";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+import { createSeq, encodeSse, type AgUiEvent } from "@omniroute/open-sse/ag-ui/index.ts";
+
+export async function GET(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ if (!isFeatureFlagEnabled("LOOP_ENGINE_ENABLED")) {
+ return NextResponse.json({ error: "Loop Engine is disabled." }, { status: 404 });
+ }
+ const { id } = await params;
+ const run = getLoopRun(id);
+ if (!run) return NextResponse.json({ error: "run not found" }, { status: 404 });
+
+ const seq = createSeq();
+ const events: AgUiEvent[] = [{ seq: seq(), runId: run.id, type: "RUN_STARTED" }];
+ for (const step of run.steps) {
+ const messageId = `step-${step.index}`;
+ events.push({
+ seq: seq(),
+ runId: run.id,
+ type: "TEXT_MESSAGE_START",
+ messageId,
+ role: "assistant",
+ });
+ const effect =
+ step.proposedEffect && step.proposedEffect.kind !== "none"
+ ? ` [efeito proposto: ${step.proposedEffect.kind}]`
+ : "";
+ events.push({
+ seq: seq(),
+ runId: run.id,
+ type: "TEXT_MESSAGE_CONTENT",
+ messageId,
+ delta: `${step.index + 1}. ${step.title} (${step.status})${effect}`,
+ });
+ events.push({ seq: seq(), runId: run.id, type: "TEXT_MESSAGE_END", messageId });
+ }
+ events.push({
+ seq: seq(),
+ runId: run.id,
+ type: "STATE_SNAPSHOT",
+ state: { phase: run.phase, status: run.status, sequenceNumber: run.sequenceNumber },
+ });
+ events.push({ seq: seq(), runId: run.id, type: "RUN_FINISHED" });
+
+ const body = events.map(encodeSse).join("");
+ return new Response(body, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/event-stream; charset=utf-8",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ },
+ });
+}
diff --git a/src/app/api/loop/route.ts b/src/app/api/loop/route.ts
new file mode 100644
index 00000000000..50b1cec54b8
--- /dev/null
+++ b/src/app/api/loop/route.ts
@@ -0,0 +1,67 @@
+/**
+ * GET /api/loop — lista runs do Loop Engine (opcional ?status=...).
+ * POST /api/loop — inicia um run (report-only) { pattern, budget?, taskId? }.
+ *
+ * Autenticado (management) e gated pela flag LOOP_ENGINE_ENABLED. Report-only: iniciar um
+ * run NÃO executa efeito externo. Serve ao painel único e a qualquer harness.
+ */
+import { NextRequest, NextResponse } from "next/server";
+
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { listLoopRuns, startRun } from "@/lib/loopRunner";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+import type { LoopRun } from "@omniroute/open-sse/loop-engine/index.ts";
+
+const LOOP_STATUSES: ReadonlyArray = [
+ "report_only",
+ "awaiting_approval",
+ "verifying",
+ "done",
+ "failed",
+ "escalated",
+ "aborted",
+];
+
+function disabled(): NextResponse {
+ return NextResponse.json(
+ { error: "Loop Engine is disabled. Enable LOOP_ENGINE_ENABLED in the OmniRoute panel." },
+ { status: 404 }
+ );
+}
+
+export async function GET(req: NextRequest): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ if (!isFeatureFlagEnabled("LOOP_ENGINE_ENABLED")) return disabled();
+
+ const raw = new URL(req.url).searchParams.get("status");
+ const status =
+ raw && LOOP_STATUSES.includes(raw as LoopRun["status"])
+ ? (raw as LoopRun["status"])
+ : undefined;
+ return NextResponse.json({ runs: listLoopRuns(status) });
+}
+
+export async function POST(req: NextRequest): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ if (!isFeatureFlagEnabled("LOOP_ENGINE_ENABLED")) return disabled();
+
+ const body = (await req.json().catch(() => ({}))) as {
+ pattern?: unknown;
+ budget?: Record;
+ taskId?: unknown;
+ correlationId?: unknown;
+ };
+ const pattern = typeof body.pattern === "string" ? body.pattern.trim() : "";
+ if (!pattern) {
+ return NextResponse.json({ error: "pattern is required" }, { status: 400 });
+ }
+ const run = startRun({
+ pattern,
+ budget: body.budget,
+ taskId: typeof body.taskId === "string" ? body.taskId : undefined,
+ correlationId: typeof body.correlationId === "string" ? body.correlationId : undefined,
+ });
+ return NextResponse.json({ run }, { status: 201 });
+}
diff --git a/src/app/api/mcp/review/route.ts b/src/app/api/mcp/review/route.ts
new file mode 100644
index 00000000000..8dcb00dba3e
--- /dev/null
+++ b/src/app/api/mcp/review/route.ts
@@ -0,0 +1,51 @@
+/**
+ * POST /api/mcp/review — roda o MCP Review Gate determinístico sobre um candidato.
+ * Body: { candidate: McpCandidate, prior?: McpPriorApproval }.
+ *
+ * Autenticado (management) e gated por MCP_REVIEW_ENABLED. Código decide (não a IA): malicioso/
+ * permissão proibida → denied; novo ou permissão ampliada → review_required (aprovação humana).
+ */
+import { NextRequest, NextResponse } from "next/server";
+
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { traceSync } from "@/lib/otel";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+import {
+ reviewMcpCandidate,
+ type McpCandidate,
+ type McpPriorApproval,
+} from "@omniroute/open-sse/mcp-review/index.ts";
+
+export async function POST(req: NextRequest): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ if (!isFeatureFlagEnabled("MCP_REVIEW_ENABLED")) {
+ return NextResponse.json(
+ { error: "MCP Review is disabled. Enable MCP_REVIEW_ENABLED in the OmniRoute panel." },
+ { status: 404 }
+ );
+ }
+
+ const body = (await req.json().catch(() => ({}))) as {
+ candidate?: Partial;
+ prior?: McpPriorApproval;
+ };
+ const c = body.candidate;
+ if (
+ !c ||
+ typeof c.name !== "string" ||
+ typeof c.source !== "string" ||
+ typeof c.version !== "string" ||
+ !Array.isArray(c.permissions)
+ ) {
+ return NextResponse.json(
+ { error: "candidate {name, source, version, permissions[]} is required" },
+ { status: 400 }
+ );
+ }
+
+ const verdict = traceSync("mcp.review", { route: "/api/mcp/review", provider: "mcp" }, () =>
+ reviewMcpCandidate(c as McpCandidate, body.prior)
+ );
+ return NextResponse.json({ verdict });
+}
diff --git a/src/app/api/otel/spans/route.ts b/src/app/api/otel/spans/route.ts
new file mode 100644
index 00000000000..fee6b44cdcd
--- /dev/null
+++ b/src/app/api/otel/spans/route.ts
@@ -0,0 +1,25 @@
+/**
+ * GET /api/otel/spans — lê os spans recentes coletados (W3C Trace Context), sem conteúdo sensível.
+ *
+ * Autenticado (management) e gated por OTEL_TRACING_ENABLED. Os atributos já passaram pela allowlist
+ * do core (nada de prompt/resposta/PII/segredo). Útil para inspecionar latência/rota/provider.
+ */
+import { NextRequest, NextResponse } from "next/server";
+
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { recentSpans } from "@/lib/otel";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+
+export async function GET(req: NextRequest): Promise {
+ const auth = await requireManagementAuth(req);
+ if (auth) return auth;
+ if (!isFeatureFlagEnabled("OTEL_TRACING_ENABLED")) {
+ return NextResponse.json(
+ { error: "OTel tracing is disabled. Enable OTEL_TRACING_ENABLED in the OmniRoute panel." },
+ { status: 404 }
+ );
+ }
+ const raw = new URL(req.url).searchParams.get("limit");
+ const limit = Math.min(Math.max(Number(raw) || 100, 1), 500);
+ return NextResponse.json({ spans: recentSpans(limit) });
+}
diff --git a/src/app/callback/page.tsx b/src/app/callback/page.tsx
index 3be2d56b293..4f5677cea2f 100644
--- a/src/app/callback/page.tsx
+++ b/src/app/callback/page.tsx
@@ -1,5 +1,6 @@
"use client";
+import Link from "next/link";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
@@ -132,6 +133,27 @@ export default function CallbackPage() {
// loopback/tunnel callback. Keep the full URL visible as a manual fallback
// in case the opener cannot receive the cross-origin postMessage.
queueStatusUpdate("manual");
+ // Retorno automático ao OmniRoute — APENAS em máquina local (loopback) e com sucesso:
+ // o relay já foi enviado ao painel na mesma origem; tenta fechar (popup) e, se continuar
+ // aberto (era a MESMA aba), volta ao painel. Caso remoto/túnel permanece no manual.
+ const host = window.location.hostname;
+ const isLoopback = host === "localhost" || /^127(?:\.\d{1,3}){3}$/.test(host);
+ if (code && !error && isLoopback) {
+ setTimeout(() => {
+ try {
+ window.close();
+ } catch {
+ /* popup pode não fechar por política do navegador */
+ }
+ setTimeout(() => {
+ try {
+ window.location.replace("/dashboard/providers");
+ } catch {
+ /* mantém o estado manual como fallback */
+ }
+ }, 1000);
+ }, 2500);
+ }
}
} else {
// No code/error in URL or all send methods failed — show URL for manual copy.
@@ -179,6 +201,16 @@ export default function CallbackPage() {
{currentUrl}
+ {/* Login abriu na MESMA aba (sem popup/opener): caminho de volta ao OmniRoute. */}
+
+
+ arrow_back
+
+ Voltar ao OmniRoute
+
>
)}
diff --git a/src/lib/buzzConsumer.ts b/src/lib/buzzConsumer.ts
new file mode 100644
index 00000000000..344ce3f24d8
--- /dev/null
+++ b/src/lib/buzzConsumer.ts
@@ -0,0 +1,55 @@
+/**
+ * Buzz Consumer — liga o relay ao inbox do OmniRoute (fecha o gap "sem consumidor de produção").
+ *
+ * Assina o relay (flag ON) e roteia cada evento recebido para `receiveInbox` — que apenas
+ * DEDUPLICA e ARMAZENA. NÃO há caminho daqui para qualquer efeito no OmniRoute: uma chave/assinatura
+ * Nostr nunca autoriza uma ação (as decisões ficam com o Policy Engine, fora deste módulo). Inbound
+ * é só sinal/colaboração para humanos verem no painel. Opt-in e best-effort.
+ */
+import type { BuzzSubscriptionFilter } from "@omniroute/open-sse/buzz-bridge/index.ts";
+
+import { getBuzzAdapter } from "./buzzService";
+import { receiveInbox } from "./db/buzzBridge";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+
+export interface InboxSubscription {
+ started: boolean;
+ /** Encerra a assinatura e fecha a conexão. */
+ stop: () => Promise;
+}
+
+/**
+ * Inicia a assinatura do inbox. Retorna { started:false } quando a flag BUZZ_HUB_ENABLED está OFF
+ * (nada conecta). Cada evento verificado é persistido via receiveInbox (dedup); erros são engolidos
+ * para não derrubar o processo. `correlationId` de entrada = id do evento (rastreável, sem efeito).
+ */
+export async function startBuzzInboxSubscription(
+ filter: BuzzSubscriptionFilter = { kinds: [1] },
+ tenantId?: string
+): Promise {
+ if (!isFeatureFlagEnabled("BUZZ_HUB_ENABLED")) {
+ return { started: false, stop: async () => {} };
+ }
+ const adapter = getBuzzAdapter();
+ if (!adapter.enabled) return { started: false, stop: async () => {} };
+
+ await adapter.connect();
+ await adapter.subscribe(filter, (event) => {
+ try {
+ // Storage-only: dedup + persiste. NUNCA dispara efeito (Nostr não autoriza).
+ receiveInbox(event, event.id, tenantId);
+ } catch {
+ /* best-effort: um evento malformado não derruba a assinatura */
+ }
+ });
+ return {
+ started: true,
+ stop: async () => {
+ try {
+ await adapter.close();
+ } catch {
+ /* ignore */
+ }
+ },
+ };
+}
diff --git a/src/lib/buzzProducer.ts b/src/lib/buzzProducer.ts
new file mode 100644
index 00000000000..d77e9aea266
--- /dev/null
+++ b/src/lib/buzzProducer.ts
@@ -0,0 +1,56 @@
+/**
+ * Buzz Producer — liga o Loop Engine ao outbox do Buzz (fecha o gap "sem produtor de produção").
+ *
+ * Quando um run do Loop precisa de um humano (awaiting_approval) ou escala (escalated), enfileira
+ * uma notificação DURÁVEL no outbox do Buzz. Idempotente (id derivado do run+status+seq), aditivo e
+ * best-effort: uma falha do Buzz NUNCA quebra o Loop. Nada conecta aqui — a publicação real fica no
+ * flush (gated por BUZZ_HUB_ENABLED). A chave Nostr nunca autoriza ação; isto é só sinalização.
+ */
+import { getPublicKey, type BuzzEvent } from "@omniroute/open-sse/buzz-bridge/index.ts";
+
+import { enqueueOutbox } from "./db/buzzBridge";
+import { getOrCreateAgentSecretKey } from "./buzzService";
+
+export interface LoopNotice {
+ runId: string;
+ status: string;
+ pattern: string;
+ sequenceNumber: number;
+ tenantId?: string;
+}
+
+/**
+ * Enfileira uma notificação do Loop no outbox (report-only). Retorna true se enfileirou.
+ * Best-effort: engole erros (ex.: tabelas ausentes num DB mínimo) para não afetar o run.
+ */
+export function notifyLoopEvent(notice: LoopNotice): boolean {
+ try {
+ const event: BuzzEvent = {
+ // id estável → dedup no enqueue (mesma transição não vira dois eventos).
+ id: `loop:${notice.runId}:${notice.status}:${notice.sequenceNumber}`,
+ pubkey: getPublicKey(getOrCreateAgentSecretKey()),
+ kind: 1,
+ createdAt: 0, // o enqueue carimba um createdAt estável
+ tags: [
+ ["t", "loop"],
+ ["run", notice.runId],
+ ["status", notice.status],
+ ],
+ content: `Loop "${notice.pattern}" (${notice.runId}) → ${notice.status}`,
+ };
+ enqueueOutbox({
+ event,
+ correlationId: `loop:${notice.runId}`,
+ runId: notice.runId,
+ tenantId: notice.tenantId,
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Estados do Loop que merecem um aviso ao humano (aprovação/handoff). */
+export function loopStatusNeedsHuman(status: string): boolean {
+ return status === "awaiting_approval" || status === "escalated";
+}
diff --git a/src/lib/buzzService.ts b/src/lib/buzzService.ts
new file mode 100644
index 00000000000..33299528f51
--- /dev/null
+++ b/src/lib/buzzService.ts
@@ -0,0 +1,145 @@
+/**
+ * Buzz Service — integra o Buzz ao OmniRoute (config + adaptador + flush do outbox).
+ *
+ * - Identidade Nostr do agente: gerada uma vez e PERSISTIDA (key_value namespace 'buzz'),
+ * estável entre reinícios.
+ * - Config do relay: BUZZ_RELAY_URL (default ws://localhost:3000). Configurável no painel único.
+ * - flushBuzzOutbox: publica as entradas pendentes do outbox no relay real (idempotente).
+ *
+ * Gated por BUZZ_HUB_ENABLED (OFF por padrão). Sem flag/relay, tudo fica inerte e durável no DB.
+ */
+import {
+ generateSecretKey,
+ getPublicKey,
+ resolveBuzzAdapter,
+ type BuzzAdapter,
+ type WebSocketBuzzConfig,
+} from "@omniroute/open-sse/buzz-bridge/index.ts";
+
+import {
+ buzzCounts,
+ markOutbox,
+ pendingOutbox,
+ requeueFailedOutbox,
+ type BuzzCounts,
+} from "./db/buzzBridge";
+import { getDbInstance } from "./db/core";
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+
+/** Chave secreta Nostr do agente OmniRoute, persistida e estável. */
+export function getOrCreateAgentSecretKey(): string {
+ const db = getDbInstance();
+ const row = db
+ .prepare("SELECT value FROM key_value WHERE namespace = 'buzz' AND key = 'agent_sk'")
+ .get() as { value: string } | undefined;
+ if (row?.value) return row.value;
+ const sk = generateSecretKey();
+ db.prepare(
+ "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('buzz', 'agent_sk', ?)"
+ ).run(sk);
+ return sk;
+}
+
+/**
+ * URL do relay, com precedência: override do painel (key_value) → env BUZZ_RELAY_URL → default.
+ * Assim o PAINEL ÚNICO configura tudo, sem perder a opção de fixar por ambiente.
+ */
+export function getBuzzRelayUrl(): string {
+ const db = getDbInstance();
+ const row = db
+ .prepare("SELECT value FROM key_value WHERE namespace = 'buzz' AND key = 'relay_url'")
+ .get() as { value: string } | undefined;
+ return row?.value?.trim() || process.env.BUZZ_RELAY_URL?.trim() || "ws://localhost:3000";
+}
+
+/** Persiste a URL do relay definida no painel. String vazia remove o override (volta ao env/default). */
+export function setBuzzRelayUrl(url: string): string {
+ const db = getDbInstance();
+ const trimmed = url.trim();
+ if (!trimmed) {
+ db.prepare("DELETE FROM key_value WHERE namespace = 'buzz' AND key = 'relay_url'").run();
+ } else {
+ db.prepare(
+ "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('buzz', 'relay_url', ?)"
+ ).run(trimmed);
+ }
+ return getBuzzRelayUrl();
+}
+
+/** Config do relay (URL do painel/env + chave persistida). */
+export function getBuzzConfig(): WebSocketBuzzConfig {
+ return {
+ relayUrl: getBuzzRelayUrl(),
+ secretKeyHex: getOrCreateAgentSecretKey(),
+ };
+}
+
+/** Adaptador conforme a flag: real (WebSocket) quando BUZZ_HUB_ENABLED, senão inerte. */
+export function getBuzzAdapter(): BuzzAdapter {
+ const enabled = isFeatureFlagEnabled("BUZZ_HUB_ENABLED");
+ return resolveBuzzAdapter(enabled, enabled ? getBuzzConfig() : undefined);
+}
+
+export interface BuzzStatus {
+ enabled: boolean;
+ relayUrl: string;
+ /** Chave PÚBLICA Nostr do agente (nunca a secreta). Identidade estável do OmniRoute no relay. */
+ agentPubkey: string;
+ counts: BuzzCounts;
+}
+
+/**
+ * Estado do Buzz para o painel único: flag, URL do relay, pubkey do agente e contagens do
+ * outbox/inbox. NUNCA expõe a chave secreta. Não conecta ao relay (leitura local, barata).
+ */
+export function getBuzzStatus(): BuzzStatus {
+ return {
+ enabled: isFeatureFlagEnabled("BUZZ_HUB_ENABLED"),
+ relayUrl: getBuzzRelayUrl(),
+ agentPubkey: getPublicKey(getOrCreateAgentSecretKey()),
+ counts: buzzCounts(),
+ };
+}
+
+export interface FlushResult {
+ published: number;
+ failed: number;
+ skipped: boolean;
+}
+
+/**
+ * Publica as entradas pendentes do outbox no relay. Idempotente (dedup do relay por id de
+ * evento). Retorna a contagem. Skipped quando a flag está OFF ou não há relay/pendentes.
+ */
+export async function flushBuzzOutbox(limit = 50): Promise {
+ if (!isFeatureFlagEnabled("BUZZ_HUB_ENABLED")) {
+ return { published: 0, failed: 0, skipped: true };
+ }
+ // Reenfileira falhas transitórias (sob o teto de tentativas) antes de coletar as pendentes, para
+ // que uma queda passageira do relay não estrangule a mensagem em 'failed' para sempre.
+ requeueFailedOutbox();
+ const pending = pendingOutbox(limit);
+ if (pending.length === 0) return { published: 0, failed: 0, skipped: false };
+
+ const adapter = resolveBuzzAdapter(true, getBuzzConfig());
+ if (!adapter.enabled) return { published: 0, failed: 0, skipped: true };
+
+ await adapter.connect();
+ let published = 0;
+ let failed = 0;
+ try {
+ for (const entry of pending) {
+ const ok = await adapter.publish(entry);
+ if (ok) {
+ markOutbox(entry.id, "published");
+ published++;
+ } else {
+ markOutbox(entry.id, "failed");
+ failed++;
+ }
+ }
+ } finally {
+ await adapter.close();
+ }
+ return { published, failed, skipped: false };
+}
diff --git a/src/lib/db/buzzBridge.ts b/src/lib/db/buzzBridge.ts
new file mode 100644
index 00000000000..2fe494bd551
--- /dev/null
+++ b/src/lib/db/buzzBridge.ts
@@ -0,0 +1,180 @@
+/**
+ * Repositório do Buzz Bridge — persistência do outbox/inbox (tabelas da migração 174).
+ *
+ * Torna a ponte funcional mesmo com o relay AUSENTE: os eventos de saída ficam duráveis no
+ * outbox até haver relay + flag ON; os de entrada são deduplicados no inbox. Idempotente.
+ */
+import type { BuzzEvent, InboxEntry, OutboxEntry } from "@omniroute/open-sse/buzz-bridge/index.ts";
+
+import { getDbInstance } from "./core";
+import { DEFAULT_TENANT } from "./loopEngine";
+
+interface OutboxRow {
+ id: string;
+ correlation_id: string;
+ sequence_number: number;
+ task_id: string | null;
+ run_id: string | null;
+ event_json: string;
+ status: string;
+ attempts: number;
+}
+
+/** Enfileira um evento de saída (idempotente por event.id). Retorna a entrada. Escopado por tenant. */
+export function enqueueOutbox(params: {
+ event: BuzzEvent;
+ correlationId: string;
+ taskId?: string;
+ runId?: string;
+ tenantId?: string;
+}): OutboxEntry {
+ const db = getDbInstance();
+ const tenantId = params.tenantId ?? DEFAULT_TENANT;
+ // Carimba createdAt UMA vez no enqueue (se ausente) e persiste, para a publicação re-assinar
+ // sempre com o MESMO timestamp → mesmo id Nostr → dedup real do relay entre re-tentativas.
+ const event: BuzzEvent = {
+ ...params.event,
+ createdAt: params.event.createdAt || Math.floor(Date.now() / 1000),
+ };
+ const nextSeq =
+ (
+ db
+ .prepare(
+ "SELECT COALESCE(MAX(sequence_number),0) AS m FROM buzz_outbox WHERE tenant_id = ?"
+ )
+ .get(tenantId) as { m: number }
+ ).m + 1;
+ db.prepare(
+ `INSERT INTO buzz_outbox (id, tenant_id, correlation_id, sequence_number, task_id, run_id, event_json, status, attempts)
+ VALUES (@id, @tenant_id, @correlation_id, @sequence_number, @task_id, @run_id, @event_json, 'pending', 0)
+ ON CONFLICT(id) DO NOTHING`
+ ).run({
+ id: event.id,
+ tenant_id: tenantId,
+ correlation_id: params.correlationId,
+ sequence_number: nextSeq,
+ task_id: params.taskId ?? null,
+ run_id: params.runId ?? null,
+ event_json: JSON.stringify(event),
+ });
+ const row = db
+ .prepare("SELECT * FROM buzz_outbox WHERE id = ?")
+ .get(params.event.id) as OutboxRow;
+ return {
+ id: row.id,
+ correlationId: row.correlation_id,
+ sequenceNumber: row.sequence_number,
+ taskId: row.task_id ?? undefined,
+ runId: row.run_id ?? undefined,
+ event: JSON.parse(row.event_json),
+ status: row.status as OutboxEntry["status"],
+ attempts: row.attempts,
+ };
+}
+
+/** Entradas pendentes do outbox do tenant, em ordem de sequência (entrega ordenada). */
+export function pendingOutbox(limit = 100, tenantId: string = DEFAULT_TENANT): OutboxEntry[] {
+ const rows = getDbInstance()
+ .prepare(
+ "SELECT * FROM buzz_outbox WHERE tenant_id = ? AND status = 'pending' ORDER BY sequence_number LIMIT ?"
+ )
+ .all(tenantId, limit) as OutboxRow[];
+ return rows.map((row) => ({
+ id: row.id,
+ correlationId: row.correlation_id,
+ sequenceNumber: row.sequence_number,
+ taskId: row.task_id ?? undefined,
+ runId: row.run_id ?? undefined,
+ event: JSON.parse(row.event_json),
+ status: row.status as OutboxEntry["status"],
+ attempts: row.attempts,
+ }));
+}
+
+export function markOutbox(id: string, status: "published" | "failed"): void {
+ const db = getDbInstance();
+ if (status === "failed") {
+ db.prepare("UPDATE buzz_outbox SET status='failed', attempts=attempts+1 WHERE id=?").run(id);
+ } else {
+ db.prepare("UPDATE buzz_outbox SET status='published' WHERE id=?").run(id);
+ }
+}
+
+/** Teto de re-tentativas de publicação antes de desistir (evita loop infinito num relay quebrado). */
+export const MAX_OUTBOX_ATTEMPTS = 5;
+
+/**
+ * Reenfileira entradas 'failed' que ainda estão sob o teto de tentativas (failed -> pending), para
+ * o próximo flush tentar publicar de novo. Falhas costumam ser transitórias (relay fora do ar,
+ * corrida com o AUTH do NIP-42); sem isto a entrada ficaria presa em 'failed' para sempre. As que
+ * estouraram o teto permanecem 'failed' (não voltam). Retorna quantas foram reenfileiradas.
+ * Escopado por tenant. Idempotente por id (o dedup do relay cobre uma eventual republicação dupla).
+ */
+export function requeueFailedOutbox(
+ tenantId: string = DEFAULT_TENANT,
+ maxAttempts: number = MAX_OUTBOX_ATTEMPTS
+): number {
+ const res = getDbInstance()
+ .prepare(
+ "UPDATE buzz_outbox SET status='pending' WHERE tenant_id = ? AND status='failed' AND attempts < ?"
+ )
+ .run(tenantId, maxAttempts);
+ return res.changes;
+}
+
+export interface BuzzCounts {
+ outboxPending: number;
+ outboxPublished: number;
+ outboxFailed: number;
+ inboxReceived: number;
+}
+
+/** Contagens do outbox/inbox do tenant para o painel único. Zero se as tabelas não existem. */
+export function buzzCounts(tenantId: string = DEFAULT_TENANT): BuzzCounts {
+ const db = getDbInstance();
+ const count = (sql: string): number => {
+ try {
+ return (db.prepare(sql).get(tenantId) as { n: number }).n;
+ } catch {
+ return 0; // tabela ausente num DB mínimo — reporta 0 em vez de quebrar o painel
+ }
+ };
+ return {
+ outboxPending: count(
+ "SELECT COUNT(*) AS n FROM buzz_outbox WHERE tenant_id = ? AND status = 'pending'"
+ ),
+ outboxPublished: count(
+ "SELECT COUNT(*) AS n FROM buzz_outbox WHERE tenant_id = ? AND status = 'published'"
+ ),
+ outboxFailed: count(
+ "SELECT COUNT(*) AS n FROM buzz_outbox WHERE tenant_id = ? AND status = 'failed'"
+ ),
+ inboxReceived: count("SELECT COUNT(*) AS n FROM buzz_inbox WHERE tenant_id = ?"),
+ };
+}
+
+/**
+ * Registra um evento recebido (dedup por event.id). Retorna null se já visto — garante
+ * processamento no máximo uma vez, mesmo com reentrega do relay. Escopado por tenant.
+ */
+export function receiveInbox(
+ event: BuzzEvent,
+ correlationId: string,
+ tenantId: string = DEFAULT_TENANT
+): InboxEntry | null {
+ const db = getDbInstance();
+ const nextSeq =
+ (
+ db
+ .prepare("SELECT COALESCE(MAX(sequence_number),0) AS m FROM buzz_inbox WHERE tenant_id = ?")
+ .get(tenantId) as { m: number }
+ ).m + 1;
+ const res = db
+ .prepare(
+ `INSERT INTO buzz_inbox (event_id, tenant_id, correlation_id, sequence_number, event_json, status)
+ VALUES (?, ?, ?, ?, ?, 'received') ON CONFLICT(event_id) DO NOTHING`
+ )
+ .run(event.id, tenantId, correlationId, nextSeq, JSON.stringify(event));
+ if (res.changes === 0) return null; // ja visto
+ return { event, correlationId, sequenceNumber: nextSeq, status: "received" };
+}
diff --git a/src/lib/db/loopEngine.ts b/src/lib/db/loopEngine.ts
new file mode 100644
index 00000000000..630449c2659
--- /dev/null
+++ b/src/lib/db/loopEngine.ts
@@ -0,0 +1,131 @@
+/**
+ * Repositório do Loop Engine — persistência dos runs/steps (tabelas da migração 174).
+ *
+ * Torna o núcleo puro (`open-sse/loop-engine`) FUNCIONAL: grava e recarrega o estado no DB
+ * do OmniRoute (fonte de verdade). Report-only; nenhum efeito externo aqui.
+ */
+import type { LoopRun, LoopStep } from "@omniroute/open-sse/loop-engine/index.ts";
+
+import { getDbInstance } from "./core";
+
+/** Tenant único por ora (CLAUDE.md §5.1). Toda leitura/escrita é escopada por ele. */
+export const DEFAULT_TENANT = "default";
+
+interface LoopRunRow {
+ id: string;
+ pattern: string;
+ phase: string;
+ status: string;
+ budget_json: string;
+ usage_json: string;
+ correlation_id: string;
+ task_id: string | null;
+ sequence_number: number;
+}
+
+interface LoopStepRow {
+ id: string;
+ run_id: string;
+ idx: number;
+ title: string;
+ proposed_effect_json: string | null;
+ status: string;
+}
+
+function rowToRun(row: LoopRunRow, steps: LoopStep[]): LoopRun {
+ return {
+ id: row.id,
+ pattern: row.pattern,
+ phase: row.phase as LoopRun["phase"],
+ status: row.status as LoopRun["status"],
+ budget: JSON.parse(row.budget_json),
+ usage: JSON.parse(row.usage_json),
+ correlationId: row.correlation_id,
+ taskId: row.task_id ?? undefined,
+ sequenceNumber: row.sequence_number,
+ steps,
+ };
+}
+
+/** Persiste (upsert) um run e todas as suas etapas, de forma transacional. Escopado por tenant. */
+export function saveLoopRun(run: LoopRun, tenantId: string = DEFAULT_TENANT): void {
+ const db = getDbInstance();
+ const tx = db.transaction((r: LoopRun) => {
+ db.prepare(
+ `INSERT INTO loop_runs (id, tenant_id, pattern, phase, status, budget_json, usage_json, correlation_id, task_id, sequence_number, updated_at)
+ VALUES (@id, @tenant_id, @pattern, @phase, @status, @budget_json, @usage_json, @correlation_id, @task_id, @sequence_number, datetime('now'))
+ ON CONFLICT(id) DO UPDATE SET
+ phase=excluded.phase, status=excluded.status, usage_json=excluded.usage_json,
+ sequence_number=excluded.sequence_number, updated_at=datetime('now')`
+ ).run({
+ id: r.id,
+ tenant_id: tenantId,
+ pattern: r.pattern,
+ phase: r.phase,
+ status: r.status,
+ budget_json: JSON.stringify(r.budget),
+ usage_json: JSON.stringify(r.usage),
+ correlation_id: r.correlationId,
+ task_id: r.taskId ?? null,
+ sequence_number: r.sequenceNumber,
+ });
+ const upStep = db.prepare(
+ `INSERT INTO loop_steps (id, tenant_id, run_id, idx, title, proposed_effect_json, status)
+ VALUES (@id, @tenant_id, @run_id, @idx, @title, @proposed_effect_json, @status)
+ ON CONFLICT(id) DO UPDATE SET status=excluded.status`
+ );
+ for (const s of r.steps) {
+ upStep.run({
+ id: s.id,
+ tenant_id: tenantId,
+ run_id: r.id,
+ idx: s.index,
+ title: s.title,
+ proposed_effect_json: s.proposedEffect ? JSON.stringify(s.proposedEffect) : null,
+ status: s.status,
+ });
+ }
+ });
+ tx(run);
+}
+
+/** Carrega um run com suas etapas, ou null se não existir NESTE tenant (isolamento). */
+export function getLoopRun(id: string, tenantId: string = DEFAULT_TENANT): LoopRun | null {
+ const db = getDbInstance();
+ const row = db
+ .prepare("SELECT * FROM loop_runs WHERE id = ? AND tenant_id = ?")
+ .get(id, tenantId) as LoopRunRow | undefined;
+ if (!row) return null;
+ const stepRows = db
+ .prepare("SELECT * FROM loop_steps WHERE run_id = ? AND tenant_id = ? ORDER BY idx")
+ .all(id, tenantId) as LoopStepRow[];
+ const steps: LoopStep[] = stepRows.map((sr) => ({
+ id: sr.id,
+ runId: sr.run_id,
+ index: sr.idx,
+ title: sr.title,
+ proposedEffect: sr.proposed_effect_json ? JSON.parse(sr.proposed_effect_json) : undefined,
+ status: sr.status as LoopStep["status"],
+ }));
+ return rowToRun(row, steps);
+}
+
+/** Lista runs do tenant (opcionalmente por status), mais recentes primeiro. */
+export function listLoopRuns(
+ status?: LoopRun["status"],
+ tenantId: string = DEFAULT_TENANT
+): LoopRun[] {
+ const db = getDbInstance();
+ const rows = (
+ status
+ ? db
+ .prepare(
+ "SELECT * FROM loop_runs WHERE tenant_id = ? AND status = ? ORDER BY updated_at DESC"
+ )
+ .all(tenantId, status)
+ : db
+ .prepare("SELECT * FROM loop_runs WHERE tenant_id = ? ORDER BY updated_at DESC")
+ .all(tenantId)
+ ) as LoopRunRow[];
+ return rows.map((row) => getLoopRun(row.id, tenantId)!).filter(Boolean);
+}
diff --git a/src/lib/db/migrations/174_loop_engine_and_buzz_bridge.sql b/src/lib/db/migrations/174_loop_engine_and_buzz_bridge.sql
new file mode 100644
index 00000000000..09b3e52679f
--- /dev/null
+++ b/src/lib/db/migrations/174_loop_engine_and_buzz_bridge.sql
@@ -0,0 +1,65 @@
+-- 174_loop_engine_and_buzz_bridge.sql
+--
+-- Estado durável dos módulos agentic da Fase 1 (Loop Engine + Buzz Bridge). Aditiva,
+-- idempotente (IF NOT EXISTS) e não-destrutiva: só cria tabelas/índices novos, não toca
+-- nada existente. O OmniRoute permanece a fonte de verdade de runs/steps/aprovações; a
+-- ponte Buzz (outbox/inbox) é idempotente por design. Ambos os módulos ficam atrás das
+-- flags LOOP_ENGINE_ENABLED / BUZZ_HUB_ENABLED (OFF por padrão).
+
+-- ── Loop Engine ────────────────────────────────────────────
+-- Multi-tenant desde o nascimento (CLAUDE.md §5.1): toda tabela nasce com tenant_id + índice.
+-- Valor único por ora ('default'); pronto para escopo por tenant sem migração destrutiva depois.
+CREATE TABLE IF NOT EXISTS loop_runs (
+ id TEXT PRIMARY KEY,
+ tenant_id TEXT NOT NULL DEFAULT 'default',
+ pattern TEXT NOT NULL,
+ phase TEXT NOT NULL,
+ status TEXT NOT NULL,
+ budget_json TEXT NOT NULL,
+ usage_json TEXT NOT NULL,
+ correlation_id TEXT NOT NULL,
+ task_id TEXT,
+ sequence_number INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS idx_loop_runs_status ON loop_runs (tenant_id, status);
+CREATE INDEX IF NOT EXISTS idx_loop_runs_correlation ON loop_runs (correlation_id);
+
+CREATE TABLE IF NOT EXISTS loop_steps (
+ id TEXT PRIMARY KEY,
+ tenant_id TEXT NOT NULL DEFAULT 'default',
+ run_id TEXT NOT NULL,
+ idx INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ proposed_effect_json TEXT,
+ status TEXT NOT NULL DEFAULT 'proposed',
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS idx_loop_steps_run ON loop_steps (run_id, idx);
+
+-- ── Buzz Bridge (ponte idempotente OmniRoute ↔ relay Nostr) ──
+CREATE TABLE IF NOT EXISTS buzz_outbox (
+ id TEXT PRIMARY KEY, -- = event.id (dedup)
+ tenant_id TEXT NOT NULL DEFAULT 'default',
+ correlation_id TEXT NOT NULL,
+ sequence_number INTEGER NOT NULL,
+ task_id TEXT,
+ run_id TEXT,
+ event_json TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ attempts INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS idx_buzz_outbox_status ON buzz_outbox (tenant_id, status, sequence_number);
+
+CREATE TABLE IF NOT EXISTS buzz_inbox (
+ event_id TEXT PRIMARY KEY, -- dedup de entrada
+ tenant_id TEXT NOT NULL DEFAULT 'default',
+ correlation_id TEXT NOT NULL,
+ sequence_number INTEGER NOT NULL,
+ event_json TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'received',
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS idx_buzz_inbox_status ON buzz_inbox (tenant_id, status);
diff --git a/src/lib/loopRunner.ts b/src/lib/loopRunner.ts
new file mode 100644
index 00000000000..5eeddc7f9ff
--- /dev/null
+++ b/src/lib/loopRunner.ts
@@ -0,0 +1,108 @@
+/**
+ * Loop Runner — orquestra o Loop Engine de ponta a ponta (report-only, persistente).
+ *
+ * Liga o núcleo puro (`open-sse/loop-engine`) ao repositório DB (`src/lib/db/loopEngine`):
+ * inicia runs, avança um passo por vez (persistindo), para em `awaiting_approval` quando um
+ * efeito externo é proposto, e retoma após aprovação humana explícita. NUNCA executa efeito
+ * externo — o Policy Engine/aprovações do OmniRoute decidem; a execução real (quando aprovada)
+ * fica com os conectores do OmniRoute, fora deste módulo.
+ */
+import {
+ advance,
+ createLoopRun,
+ proposeStep,
+ type AdvanceInput,
+ type LoopBudget,
+ type LoopProposedEffect,
+ type LoopRun,
+} from "@omniroute/open-sse/loop-engine/index.ts";
+
+import { getLoopRun, listLoopRuns, saveLoopRun } from "./db/loopEngine";
+import { loopStatusNeedsHuman, notifyLoopEvent } from "./buzzProducer";
+
+/** Inicia um run novo (report-only) e persiste. */
+export function startRun(params: {
+ pattern: string;
+ budget?: Partial;
+ correlationId?: string;
+ taskId?: string;
+}): LoopRun {
+ const run = createLoopRun(params);
+ saveLoopRun(run);
+ return run;
+}
+
+/** Anexa uma etapa proposta a um run existente e persiste. */
+export function addStep(
+ runId: string,
+ step: { title: string; proposedEffect?: LoopProposedEffect }
+): LoopRun {
+ const run = getLoopRun(runId);
+ if (!run) throw new Error(`loop run não encontrado: ${runId}`);
+ proposeStep(run, step);
+ saveLoopRun(run);
+ return run;
+}
+
+/**
+ * Avança UM passo do run e persiste. Report-only por padrão: um efeito não auto-aprovado
+ * para o run em `awaiting_approval` (nada é executado). Retorna o novo estado + nota.
+ */
+export function advanceRun(
+ runId: string,
+ input?: Partial
+): { run: LoopRun; note: string } {
+ const current = getLoopRun(runId);
+ if (!current) throw new Error(`loop run não encontrado: ${runId}`);
+ const result = advance(current, {
+ consumed: input?.consumed,
+ verdict: input?.verdict,
+ policy: input?.policy ?? { reportOnly: true },
+ });
+ saveLoopRun(result.run);
+ // Produtor Buzz: ao ENTRAR num estado que exige humano (aprovação/handoff), enfileira um aviso
+ // durável no outbox. Só na TRANSIÇÃO (evita repetir a cada advance). Best-effort, report-only.
+ if (loopStatusNeedsHuman(result.run.status) && result.run.status !== current.status) {
+ notifyLoopEvent({
+ runId: result.run.id,
+ status: result.run.status,
+ pattern: result.run.pattern,
+ sequenceNumber: result.run.sequenceNumber,
+ });
+ }
+ return result;
+}
+
+/**
+ * Aprova explicitamente uma etapa que estava aguardando aprovação, liberando o run para
+ * prosseguir. Só um humano/operador chama isto (via painel/endpoint autenticado).
+ */
+export function approveStep(runId: string, stepId: string): LoopRun {
+ const run = getLoopRun(runId);
+ if (!run) throw new Error(`loop run não encontrado: ${runId}`);
+ const step = run.steps.find((s) => s.id === stepId);
+ if (!step) throw new Error(`etapa não encontrada: ${stepId}`);
+ step.status = "approved";
+ // libera o run que estava parado para aprovação
+ if (run.status === "awaiting_approval") run.status = "report_only";
+ saveLoopRun(run);
+ return run;
+}
+
+/** Rejeita uma etapa (marca como rejeitada); o run pode então escalar/abortar no próximo advance. */
+export function rejectStep(runId: string, stepId: string): LoopRun {
+ const run = getLoopRun(runId);
+ if (!run) throw new Error(`loop run não encontrado: ${runId}`);
+ const step = run.steps.find((s) => s.id === stepId);
+ if (!step) throw new Error(`etapa não encontrada: ${stepId}`);
+ step.status = "rejected";
+ saveLoopRun(run);
+ return run;
+}
+
+/** Runs que estão parados aguardando aprovação humana (para o painel destacar). */
+export function runsAwaitingApproval(): LoopRun[] {
+ return listLoopRuns("awaiting_approval");
+}
+
+export { getLoopRun, listLoopRuns };
diff --git a/src/lib/otel.ts b/src/lib/otel.ts
new file mode 100644
index 00000000000..971c6e561e1
--- /dev/null
+++ b/src/lib/otel.ts
@@ -0,0 +1,50 @@
+/**
+ * OTel server singleton — exportador em memória (ring buffer) + helper de trace por requisição.
+ *
+ * Torna o core `open-sse/otel` USÁVEL no runtime: um exportador único guarda os últimos spans
+ * (sem conteúdo sensível — a allowlist do core garante) para inspeção via GET /api/otel/spans.
+ * Só coleta quando OTEL_TRACING_ENABLED está ON. Nada bloqueia a requisição.
+ */
+import {
+ InMemorySpanExporter,
+ endSpan,
+ startSpan,
+ type Span,
+} from "@omniroute/open-sse/otel/index.ts";
+
+import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
+
+const MAX_SPANS = 500;
+
+class RingExporter extends InMemorySpanExporter {
+ export(span: Span): void {
+ super.export(span);
+ if (this.spans.length > MAX_SPANS) this.spans.splice(0, this.spans.length - MAX_SPANS);
+ }
+}
+
+// Singleton por processo.
+const g = globalThis as unknown as { __omniOtel?: RingExporter };
+export const otelExporter: RingExporter = (g.__omniOtel ??= new RingExporter());
+
+/** Spans recentes (mais novos por último). Cópia rasa para leitura segura. */
+export function recentSpans(limit = 100): Span[] {
+ return otelExporter.spans.slice(-limit);
+}
+
+/**
+ * Envolve uma operação síncrona num span (só quando a flag está ON). Atributos passam pela
+ * allowlist do core (nada sensível). Retorna o resultado da operação intacto.
+ */
+export function traceSync(name: string, attributes: Record, fn: () => T): T {
+ if (!isFeatureFlagEnabled("OTEL_TRACING_ENABLED")) return fn();
+ const span = startSpan(name, undefined, attributes);
+ try {
+ const out = fn();
+ endSpan(span, otelExporter, { status: "ok" });
+ return out;
+ } catch (e) {
+ endSpan(span, otelExporter, { status: "error", attributes: { "error.kind": "exception" } });
+ throw e;
+ }
+}
diff --git a/src/lib/piiSanitizer.ts b/src/lib/piiSanitizer.ts
index 33f750ef61d..be398669d1c 100644
--- a/src/lib/piiSanitizer.ts
+++ b/src/lib/piiSanitizer.ts
@@ -80,6 +80,21 @@ const PII_PATTERNS: PIIPattern[] = [
replacement: "[CNPJ_REDACTED]",
severity: "high",
},
+ {
+ // CEP brasileiro NNNNN-NNN (exige hífen; boundary de 3 dígitos descarta ZIP+4 dos EUA).
+ name: "cep",
+ regex: /(?<=^|[^A-Za-z0-9])\d{5}-\d{3}(?=$|[^A-Za-z0-9])/g,
+ replacement: "[CEP_REDACTED]",
+ severity: "medium",
+ },
+ {
+ // Chave PIX aleatória (UUID v4) redigida só com a pista "pix" por perto (evita nuke de UUIDs).
+ name: "pix_key",
+ regex:
+ /(?<=\bpix\b[^\n]{0,30})[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/gi,
+ replacement: "[PIX_KEY_REDACTED]",
+ severity: "high",
+ },
{
name: "ip_address",
regex: /(?<=^|[^A-Za-z0-9])(?:\d{1,3}\.){3}\d{1,3}(?=$|[^A-Za-z0-9])/g,
diff --git a/src/shared/components/OAuthModalPanels.tsx b/src/shared/components/OAuthModalPanels.tsx
index a691a20a398..ef42cc3d0df 100644
--- a/src/shared/components/OAuthModalPanels.tsx
+++ b/src/shared/components/OAuthModalPanels.tsx
@@ -348,6 +348,18 @@ export function OAuthManualInputPanel({
{t("step1OpenUrl")}
+ {/* Abrir em NOVA ABA (gesto do usuário → o navegador não bloqueia e não abre por cima
+ do painel). O painel segue aberto e recebe o retorno do login. */}
+