docs: establish canonical documentation architecture (#1210)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/publish Pipeline failed
This commit was merged in pull request #1210.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# Gateway Security Hardening Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Finish the requested gateway security hardening fixes in the existing `fix/gateway-security` worktree and produce a PR-ready branch.
|
||||
|
||||
**Architecture:** Tighten NestJS gateway boundaries in-place by enforcing auth guards, session validation, ownership checks, DTO validation, and Fastify security defaults. Preserve the current module structure and existing ESM import conventions.
|
||||
|
||||
**Tech Stack:** NestJS 11, Fastify, Socket.IO, Better Auth, class-validator, Vitest, pnpm, TypeScript ESM
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Reconcile Security Tests
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/gateway/src/chat/__tests__/chat-security.test.ts`
|
||||
- Modify: `apps/gateway/src/__tests__/resource-ownership.test.ts`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
- Encode the requested DTO constraints and socket-auth contract exactly.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm --filter @mosaicstack/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts`
|
||||
|
||||
Expected: FAIL on current DTO/helper mismatch.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Update DTO/helper/controller code only where tests prove a gap.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run the same command and require green.
|
||||
|
||||
### Task 2: Align Gateway Runtime Hardening
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/gateway/src/conversations/conversations.dto.ts`
|
||||
- Modify: `apps/gateway/src/chat/chat.dto.ts`
|
||||
- Modify: `apps/gateway/src/chat/chat.gateway-auth.ts`
|
||||
- Modify: `apps/gateway/src/chat/chat.gateway.ts`
|
||||
- Modify: `apps/gateway/src/main.ts`
|
||||
- Modify: `apps/gateway/src/app.module.ts`
|
||||
|
||||
**Step 1: Verify remaining requested deltas**
|
||||
|
||||
- Confirm code matches requested guard, rate limit, helmet, body limit, env validation, and CORS settings.
|
||||
|
||||
**Step 2: Apply minimal patch**
|
||||
|
||||
- Keep changes scoped to requested behavior only.
|
||||
|
||||
**Step 3: Run targeted tests**
|
||||
|
||||
Run: `pnpm --filter @mosaicstack/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Verification, Review, and Delivery
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `docs/reports/code-review/gateway-security-20260313.md`
|
||||
- Create: `docs/reports/qa/gateway-security-20260313.md`
|
||||
- Modify: `docs/scratchpads/gateway-security-20260313.md`
|
||||
|
||||
**Step 1: Run baseline gates**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
**Step 2: Perform manual code review**
|
||||
|
||||
- Record correctness/security/testing/doc findings.
|
||||
|
||||
**Step 3: Commit and publish**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting"
|
||||
git push origin fix/gateway-security
|
||||
```
|
||||
|
||||
**Step 4: Open PR and notify**
|
||||
|
||||
- Open PR titled `fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting`
|
||||
- Run `openclaw system event --text "PR ready: mosaic-mono-v1 fix/gateway-security — 7 security fixes" --mode now`
|
||||
- Remove worktree after PR is created.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,998 @@
|
||||
# Wave 2 — TUI Layout & Navigation Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add conversation sidebar, keybindings, scrollable message history, and search to the Mosaic TUI.
|
||||
|
||||
**Architecture:** The TUI gains a sidebar panel for conversation management (list/create/switch) fetched via REST from the gateway. A `useConversations` hook manages REST calls. A `useScrollableViewport` hook wraps the message list with virtual viewport logic. An app-level focus/mode state machine (`useAppMode`) controls which panel receives input. All new socket events for conversation listing use the existing REST API (`GET /api/conversations`).
|
||||
|
||||
**Tech Stack:** Ink 5, React 18, socket.io-client, fetch (for REST), @mosaicstack/types
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
TUI-010 (scrollable history) ← TUI-011 (search)
|
||||
TUI-008 (sidebar) ← TUI-009 (keybindings)
|
||||
```
|
||||
|
||||
TUI-008 and TUI-010 are independent — can be built in parallel.
|
||||
TUI-009 depends on TUI-008. TUI-011 depends on TUI-010.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: TUI-010 — Scrollable Message History
|
||||
|
||||
### 1A: Create `use-viewport` hook
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/tui/hooks/use-viewport.ts`
|
||||
|
||||
**Step 1: Write the hook**
|
||||
|
||||
This hook tracks a scroll offset and viewport height for the message list.
|
||||
Ink's `useStdout` gives us terminal rows. We calculate visible slice.
|
||||
|
||||
```ts
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useStdout } from 'ink';
|
||||
|
||||
export interface UseViewportOptions {
|
||||
/** Total number of renderable lines (message count as proxy) */
|
||||
totalItems: number;
|
||||
/** Lines reserved for chrome (top bar, input bar, bottom bar) */
|
||||
reservedLines?: number;
|
||||
}
|
||||
|
||||
export interface UseViewportReturn {
|
||||
/** Index of first visible item (0-based) */
|
||||
scrollOffset: number;
|
||||
/** Number of items that fit in viewport */
|
||||
viewportSize: number;
|
||||
/** Whether user has scrolled up from bottom */
|
||||
isScrolledUp: boolean;
|
||||
/** Scroll to bottom (auto-follow mode) */
|
||||
scrollToBottom: () => void;
|
||||
/** Scroll by delta (negative = up, positive = down) */
|
||||
scrollBy: (delta: number) => void;
|
||||
/** Scroll to a specific offset */
|
||||
scrollTo: (offset: number) => void;
|
||||
/** Whether we can scroll up/down */
|
||||
canScrollUp: boolean;
|
||||
canScrollDown: boolean;
|
||||
}
|
||||
|
||||
export function useViewport(opts: UseViewportOptions): UseViewportReturn {
|
||||
const { totalItems, reservedLines = 10 } = opts;
|
||||
const { stdout } = useStdout();
|
||||
const terminalRows = stdout?.rows ?? 24;
|
||||
|
||||
// Viewport = terminal height minus chrome
|
||||
const viewportSize = Math.max(1, terminalRows - reservedLines);
|
||||
|
||||
const maxOffset = Math.max(0, totalItems - viewportSize);
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
// Track if user explicitly scrolled up
|
||||
const [autoFollow, setAutoFollow] = useState(true);
|
||||
|
||||
// Effective offset: if auto-following, always show latest
|
||||
const effectiveOffset = autoFollow ? maxOffset : Math.min(scrollOffset, maxOffset);
|
||||
|
||||
const scrollBy = useCallback(
|
||||
(delta: number) => {
|
||||
setAutoFollow(false);
|
||||
setScrollOffset((prev) => {
|
||||
const next = Math.max(0, Math.min(prev + delta, maxOffset));
|
||||
// If scrolled to bottom, re-enable auto-follow
|
||||
if (next >= maxOffset) {
|
||||
setAutoFollow(true);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[maxOffset],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setAutoFollow(true);
|
||||
setScrollOffset(maxOffset);
|
||||
}, [maxOffset]);
|
||||
|
||||
const scrollTo = useCallback(
|
||||
(offset: number) => {
|
||||
const clamped = Math.max(0, Math.min(offset, maxOffset));
|
||||
setAutoFollow(clamped >= maxOffset);
|
||||
setScrollOffset(clamped);
|
||||
},
|
||||
[maxOffset],
|
||||
);
|
||||
|
||||
return {
|
||||
scrollOffset: effectiveOffset,
|
||||
viewportSize,
|
||||
isScrolledUp: !autoFollow,
|
||||
scrollToBottom,
|
||||
scrollBy,
|
||||
scrollTo,
|
||||
canScrollUp: effectiveOffset > 0,
|
||||
canScrollDown: effectiveOffset < maxOffset,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @mosaicstack/cli typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/cli/src/tui/hooks/use-viewport.ts
|
||||
git commit -m "feat(cli): add use-viewport hook for scrollable message history"
|
||||
```
|
||||
|
||||
### 1B: Integrate viewport into MessageList and wire PgUp/PgDn
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/cli/src/tui/components/message-list.tsx`
|
||||
- Modify: `packages/cli/src/tui/app.tsx`
|
||||
|
||||
**Step 1: Update MessageList to accept viewport props and slice messages**
|
||||
|
||||
In `message-list.tsx`, add viewport props and render only the visible slice. Add a scroll indicator when scrolled up.
|
||||
|
||||
```tsx
|
||||
// Add to MessageListProps:
|
||||
export interface MessageListProps {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
currentStreamText: string;
|
||||
currentThinkingText: string;
|
||||
activeToolCalls: ToolCall[];
|
||||
// New viewport props
|
||||
scrollOffset: number;
|
||||
viewportSize: number;
|
||||
isScrolledUp: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
In the component body, slice messages:
|
||||
|
||||
```tsx
|
||||
const visibleMessages = messages.slice(scrollOffset, scrollOffset + viewportSize);
|
||||
```
|
||||
|
||||
Replace `messages.map(...)` with `visibleMessages.map(...)`. Add a scroll-up indicator at the top:
|
||||
|
||||
```tsx
|
||||
{
|
||||
isScrolledUp && (
|
||||
<Box justifyContent="center">
|
||||
<Text dimColor>↑ {scrollOffset} more messages ↑</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Wire viewport hook + keybindings in app.tsx**
|
||||
|
||||
In `app.tsx`:
|
||||
|
||||
1. Import and call `useViewport({ totalItems: socket.messages.length })`
|
||||
2. Pass viewport props to `<MessageList>`
|
||||
3. Add PgUp/PgDn/Home/End keybindings in the existing `useInput`:
|
||||
- `key.pageUp` → `viewport.scrollBy(-viewport.viewportSize)`
|
||||
- `key.pageDown` → `viewport.scrollBy(viewport.viewportSize)`
|
||||
- Shift+Up → `viewport.scrollBy(-1)` (line scroll)
|
||||
- Shift+Down → `viewport.scrollBy(1)` (line scroll)
|
||||
|
||||
Note: Ink's `useInput` key object supports `pageUp`, `pageDown`. For Home/End, check `key.meta && ch === '<'` / `key.meta && ch === '>'` as Ink doesn't have built-in home/end.
|
||||
|
||||
**Step 3: Typecheck and lint**
|
||||
|
||||
Run: `pnpm --filter @mosaicstack/cli typecheck && pnpm --filter @mosaicstack/cli lint`
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/cli/src/tui/components/message-list.tsx packages/cli/src/tui/app.tsx
|
||||
git commit -m "feat(cli): scrollable message history with PgUp/PgDn viewport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: TUI-008 — Conversation Sidebar
|
||||
|
||||
### 2A: Create `use-conversations` hook (REST client)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/tui/hooks/use-conversations.ts`
|
||||
|
||||
**Step 1: Write the hook**
|
||||
|
||||
This hook fetches conversations from the gateway REST API and provides create/switch actions.
|
||||
|
||||
```ts
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsOptions {
|
||||
gatewayUrl: string;
|
||||
sessionCookie?: string;
|
||||
/** Currently active conversation ID from socket */
|
||||
activeConversationId: string | undefined;
|
||||
}
|
||||
|
||||
export interface UseConversationsReturn {
|
||||
conversations: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
createConversation: (title?: string) => Promise<ConversationSummary | null>;
|
||||
deleteConversation: (id: string) => Promise<boolean>;
|
||||
renameConversation: (id: string, title: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
|
||||
const { gatewayUrl, sessionCookie } = opts;
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary[];
|
||||
if (mountedRef.current) {
|
||||
setConversations(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, [gatewayUrl, sessionCookie]);
|
||||
|
||||
const createConversation = useCallback(
|
||||
async (title?: string): Promise<ConversationSummary | null> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ title: title ?? 'New Conversation' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const conv = (await res.json()) as ConversationSummary;
|
||||
await refresh();
|
||||
return conv;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, sessionCookie, refresh],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
await refresh();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, sessionCookie, refresh],
|
||||
);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
await refresh();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, sessionCookie, refresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
conversations,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @mosaicstack/cli typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/cli/src/tui/hooks/use-conversations.ts
|
||||
git commit -m "feat(cli): add use-conversations hook for REST conversation management"
|
||||
```
|
||||
|
||||
### 2B: Create `use-app-mode` hook (focus/mode state machine)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/tui/hooks/use-app-mode.ts`
|
||||
|
||||
**Step 1: Write the hook**
|
||||
|
||||
This manages which panel has focus and the current UI mode.
|
||||
|
||||
```ts
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type AppMode = 'chat' | 'sidebar' | 'search';
|
||||
|
||||
export interface UseAppModeReturn {
|
||||
mode: AppMode;
|
||||
setMode: (mode: AppMode) => void;
|
||||
toggleSidebar: () => void;
|
||||
/** Whether sidebar panel should be visible */
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
export function useAppMode(): UseAppModeReturn {
|
||||
const [mode, setModeState] = useState<AppMode>('chat');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const setMode = useCallback((m: AppMode) => {
|
||||
setModeState(m);
|
||||
if (m === 'sidebar') setSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (!next) {
|
||||
// Closing sidebar → return to chat mode
|
||||
setModeState('chat');
|
||||
} else {
|
||||
setModeState('sidebar');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { mode, setMode, toggleSidebar, sidebarOpen };
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @mosaicstack/cli typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/cli/src/tui/hooks/use-app-mode.ts
|
||||
git commit -m "feat(cli): add use-app-mode hook for panel focus state machine"
|
||||
```
|
||||
|
||||
### 2C: Create `Sidebar` component
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/tui/components/sidebar.tsx`
|
||||
|
||||
**Step 1: Write the component**
|
||||
|
||||
The sidebar shows a scrollable list of conversations with the active one highlighted. It handles keyboard navigation when focused.
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { ConversationSummary } from '../hooks/use-conversations.js';
|
||||
|
||||
export interface SidebarProps {
|
||||
conversations: ConversationSummary[];
|
||||
activeConversationId: string | undefined;
|
||||
selectedIndex: number;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onSwitchConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
loading: boolean;
|
||||
focused: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) {
|
||||
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
conversations,
|
||||
activeConversationId,
|
||||
selectedIndex,
|
||||
onSelectIndex,
|
||||
onSwitchConversation,
|
||||
onDeleteConversation,
|
||||
loading,
|
||||
focused,
|
||||
width,
|
||||
}: SidebarProps) {
|
||||
useInput(
|
||||
(ch, key) => {
|
||||
if (!focused) return;
|
||||
|
||||
if (key.upArrow) {
|
||||
onSelectIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
|
||||
} else if (key.return) {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) onSwitchConversation(conv.id);
|
||||
} else if (ch === 'd' || ch === 'D') {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv && conv.id !== activeConversationId) {
|
||||
onDeleteConversation(conv.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const titleWidth = width - 4; // padding + borders
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
borderStyle="single"
|
||||
borderColor={focused ? 'cyan' : 'gray'}
|
||||
>
|
||||
<Box paddingX={1}>
|
||||
<Text bold color={focused ? 'cyan' : undefined}>
|
||||
Conversations
|
||||
</Text>
|
||||
{loading && <Text dimColor> …</Text>}
|
||||
</Box>
|
||||
|
||||
{conversations.length === 0 && (
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>No conversations</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{conversations.map((conv, i) => {
|
||||
const isActive = conv.id === activeConversationId;
|
||||
const isSelected = i === selectedIndex && focused;
|
||||
const title = conv.title ?? `Untitled (${conv.id.slice(0, 6)})`;
|
||||
const displayTitle = truncate(title, titleWidth);
|
||||
|
||||
return (
|
||||
<Box key={conv.id} paddingX={1}>
|
||||
<Text
|
||||
bold={isActive}
|
||||
color={isSelected ? 'cyan' : isActive ? 'green' : undefined}
|
||||
inverse={isSelected}
|
||||
>
|
||||
{isActive ? '● ' : ' '}
|
||||
{displayTitle}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{focused && (
|
||||
<Box paddingX={1} marginTop={1}>
|
||||
<Text dimColor>↑↓ navigate · ↵ switch · d delete</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @mosaicstack/cli typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/cli/src/tui/components/sidebar.tsx
|
||||
git commit -m "feat(cli): add conversation sidebar component"
|
||||
```
|
||||
|
||||
### 2D: Wire sidebar + conversation switching into app.tsx
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/cli/src/tui/app.tsx`
|
||||
- Modify: `packages/cli/src/tui/hooks/use-socket.ts`
|
||||
|
||||
**Step 1: Add `switchConversation` to useSocket**
|
||||
|
||||
In `use-socket.ts`, add a method to switch conversations. When switching, clear local messages and set the new conversation ID. The socket will pick up the new conversation on next `message` emit.
|
||||
|
||||
Add to `UseSocketReturn`:
|
||||
|
||||
```ts
|
||||
switchConversation: (id: string) => void;
|
||||
clearMessages: () => void;
|
||||
```
|
||||
|
||||
Implementation:
|
||||
|
||||
```ts
|
||||
const switchConversation = useCallback((id: string) => {
|
||||
setConversationId(id);
|
||||
setMessages([]);
|
||||
setIsStreaming(false);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
}, []);
|
||||
```
|
||||
|
||||
**Step 2: Update app.tsx layout to include sidebar**
|
||||
|
||||
1. Import `useAppMode`, `useConversations`, `Sidebar`
|
||||
2. Add `useAppMode()` call
|
||||
3. Add `useConversations({ gatewayUrl, sessionCookie, activeConversationId: socket.conversationId })`
|
||||
4. Track `sidebarSelectedIndex` state
|
||||
5. Wrap the main content area in a horizontal `<Box>`:
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
{appMode.sidebarOpen && (
|
||||
<Sidebar
|
||||
conversations={convos.conversations}
|
||||
activeConversationId={socket.conversationId}
|
||||
selectedIndex={sidebarSelectedIndex}
|
||||
onSelectIndex={setSidebarSelectedIndex}
|
||||
onSwitchConversation={(id) => {
|
||||
socket.switchConversation(id);
|
||||
appMode.setMode('chat');
|
||||
}}
|
||||
onDeleteConversation={(id) => void convos.deleteConversation(id)}
|
||||
loading={convos.loading}
|
||||
focused={appMode.mode === 'sidebar'}
|
||||
width={30}
|
||||
/>
|
||||
)}
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<MessageList ... />
|
||||
</Box>
|
||||
</Box>
|
||||
```
|
||||
|
||||
6. InputBar should be disabled (or readonly placeholder) when mode is not 'chat'
|
||||
|
||||
**Step 3: Typecheck and lint**
|
||||
|
||||
Run: `pnpm --filter @mosaicstack/cli typecheck && pnpm --filter @mosaicstack/cli lint`
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/cli/src/tui/app.tsx packages/cli/src/tui/hooks/use-socket.ts
|
||||
git commit -m "feat(cli): wire conversation sidebar with create/switch/delete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: TUI-009 — Keybinding System
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/cli/src/tui/app.tsx`
|
||||
|
||||
**Step 1: Add global keybindings in the existing useInput**
|
||||
|
||||
Add these bindings to the `useInput` in `app.tsx`:
|
||||
|
||||
| Binding | Action |
|
||||
| ----------- | ----------------------------------------- |
|
||||
| `Ctrl+L` | Toggle sidebar visibility |
|
||||
| `Ctrl+N` | Create new conversation + switch to it |
|
||||
| `Ctrl+K` | Toggle search mode (TUI-011) |
|
||||
| `Escape` | Return to chat mode from any panel |
|
||||
| `Ctrl+T` | Cycle thinking level (already exists) |
|
||||
| `PgUp/PgDn` | Scroll viewport (already wired in Task 1) |
|
||||
|
||||
```ts
|
||||
useInput((ch, key) => {
|
||||
if (key.ctrl && ch === 'c') {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Global keybindings (work in any mode)
|
||||
if (key.ctrl && ch === 'l') {
|
||||
appMode.toggleSidebar();
|
||||
if (!appMode.sidebarOpen) void convos.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.ctrl && ch === 'n') {
|
||||
void convos.createConversation().then((conv) => {
|
||||
if (conv) {
|
||||
socket.switchConversation(conv.id);
|
||||
appMode.setMode('chat');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.ctrl && ch === 'k') {
|
||||
appMode.setMode(appMode.mode === 'search' ? 'chat' : 'search');
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (appMode.mode !== 'chat') {
|
||||
appMode.setMode('chat');
|
||||
return;
|
||||
}
|
||||
// In chat mode, Escape could scroll to bottom
|
||||
viewport.scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+T: cycle thinking (existing)
|
||||
if (key.ctrl && ch === 't') {
|
||||
const levels = socket.availableThinkingLevels;
|
||||
if (levels.length > 0) {
|
||||
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
||||
const nextIdx = (currentIdx + 1) % levels.length;
|
||||
const next = levels[nextIdx];
|
||||
if (next) socket.setThinkingLevel(next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Viewport scrolling (only in chat mode)
|
||||
if (appMode.mode === 'chat') {
|
||||
if (key.pageUp) {
|
||||
viewport.scrollBy(-viewport.viewportSize);
|
||||
} else if (key.pageDown) {
|
||||
viewport.scrollBy(viewport.viewportSize);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Add keybinding hints to bottom bar**
|
||||
|
||||
In `bottom-bar.tsx`, add a hints line above the status lines (or integrate into line 1):
|
||||
|
||||
```tsx
|
||||
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
|
||||
```
|
||||
|
||||
**Step 3: Typecheck and lint**
|
||||
|
||||
Run: `pnpm --filter @mosaicstack/cli typecheck && pnpm --filter @mosaicstack/cli lint`
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/cli/src/tui/app.tsx packages/cli/src/tui/components/bottom-bar.tsx
|
||||
git commit -m "feat(cli): keybinding system — Ctrl+L sidebar, Ctrl+N new, Ctrl+K search, Escape"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: TUI-011 — Message Search
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/tui/components/search-bar.tsx`
|
||||
- Create: `packages/cli/src/tui/hooks/use-search.ts`
|
||||
- Modify: `packages/cli/src/tui/app.tsx`
|
||||
- Modify: `packages/cli/src/tui/components/message-list.tsx`
|
||||
|
||||
### 4A: Create `use-search` hook
|
||||
|
||||
**Step 1: Write the hook**
|
||||
|
||||
```ts
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { Message } from './use-socket.js';
|
||||
|
||||
export interface SearchMatch {
|
||||
messageIndex: number;
|
||||
/** Character offset within message content */
|
||||
charOffset: number;
|
||||
}
|
||||
|
||||
export interface UseSearchReturn {
|
||||
query: string;
|
||||
setQuery: (q: string) => void;
|
||||
matches: SearchMatch[];
|
||||
currentMatchIndex: number;
|
||||
nextMatch: () => void;
|
||||
prevMatch: () => void;
|
||||
clear: () => void;
|
||||
/** Total match count */
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export function useSearch(messages: Message[]): UseSearchReturn {
|
||||
const [query, setQuery] = useState('');
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!query || query.length < 2) return [];
|
||||
const q = query.toLowerCase();
|
||||
const result: SearchMatch[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const content = messages[i]!.content.toLowerCase();
|
||||
let pos = 0;
|
||||
while ((pos = content.indexOf(q, pos)) !== -1) {
|
||||
result.push({ messageIndex: i, charOffset: pos });
|
||||
pos += q.length;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [query, messages]);
|
||||
|
||||
const nextMatch = useCallback(() => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev + 1) % matches.length);
|
||||
}, [matches.length]);
|
||||
|
||||
const prevMatch = useCallback(() => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev - 1 + matches.length) % matches.length);
|
||||
}, [matches.length]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setQuery('');
|
||||
setCurrentMatchIndex(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
matches,
|
||||
currentMatchIndex: matches.length > 0 ? currentMatchIndex % matches.length : 0,
|
||||
nextMatch,
|
||||
prevMatch,
|
||||
clear,
|
||||
totalMatches: matches.length,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/cli/src/tui/hooks/use-search.ts
|
||||
git commit -m "feat(cli): add use-search hook for message search"
|
||||
```
|
||||
|
||||
### 4B: Create SearchBar component
|
||||
|
||||
**Step 1: Write the component**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
export interface SearchBarProps {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
totalMatches: number;
|
||||
currentMatch: number;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onClose: () => void;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
query,
|
||||
onQueryChange,
|
||||
totalMatches,
|
||||
currentMatch,
|
||||
onClose,
|
||||
focused,
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
<Box paddingX={1} borderStyle="single" borderColor={focused ? 'yellow' : 'gray'}>
|
||||
<Text color="yellow">🔍 </Text>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
placeholder="search messages…"
|
||||
focus={focused}
|
||||
/>
|
||||
<Box marginLeft={1}>
|
||||
{query.length >= 2 ? (
|
||||
<Text dimColor>
|
||||
{totalMatches > 0 ? `${currentMatch + 1}/${totalMatches}` : 'no matches'}
|
||||
{' · ↑↓ navigate · Esc close'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>type to search…</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/cli/src/tui/components/search-bar.tsx
|
||||
git commit -m "feat(cli): add search bar component"
|
||||
```
|
||||
|
||||
### 4C: Wire search into app.tsx and message-list
|
||||
|
||||
**Step 1: Integrate**
|
||||
|
||||
In `app.tsx`:
|
||||
|
||||
1. Import `useSearch` and `SearchBar`
|
||||
2. Call `useSearch(socket.messages)`
|
||||
3. When mode is 'search', render `<SearchBar>` above `<InputBar>`
|
||||
4. In search mode, Up/Down arrows call `search.nextMatch()`/`search.prevMatch()` and scroll the viewport to the matched message
|
||||
5. Pass `searchHighlights` to `MessageList` — the set of message indices that match
|
||||
|
||||
In `message-list.tsx`:
|
||||
|
||||
1. Add optional `highlightedMessageIndices?: Set<number>` and `currentHighlightIndex?: number` props
|
||||
2. Highlighted messages get a yellow left border or background tint
|
||||
3. The current match gets a brighter highlight
|
||||
|
||||
**Step 2: Typecheck and lint**
|
||||
|
||||
Run: `pnpm --filter @mosaicstack/cli typecheck && pnpm --filter @mosaicstack/cli lint`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/cli/src/tui/app.tsx packages/cli/src/tui/components/message-list.tsx packages/cli/src/tui/components/search-bar.tsx
|
||||
git commit -m "feat(cli): wire message search with highlight and viewport scroll"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Final Integration & Quality Gates
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/TASKS-TUI_Improvements.md` (update status)
|
||||
|
||||
**Step 1: Full typecheck across all affected packages**
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/cli typecheck && pnpm --filter @mosaicstack/cli lint
|
||||
pnpm --filter @mosaicstack/types typecheck
|
||||
```
|
||||
|
||||
Expected: All PASS
|
||||
|
||||
**Step 2: Manual smoke test (held)**
|
||||
|
||||
This historical TUI smoke test is unavailable until KBN-101-02 supplies a fail-closed Gateway local
|
||||
startup route. Do not start current Compose PostgreSQL or infer a local Gateway from PGlite support.
|
||||
A future reviewed test must use the correct Mosaic CLI package and an independently verified Gateway.
|
||||
|
||||
Verify:
|
||||
|
||||
- [ ] Messages scroll with PgUp/PgDn
|
||||
- [ ] Ctrl+L opens/closes sidebar
|
||||
- [ ] Sidebar shows conversations from REST API
|
||||
- [ ] Arrow keys navigate sidebar when focused
|
||||
- [ ] Enter switches conversation, clears messages
|
||||
- [ ] Ctrl+N creates new conversation
|
||||
- [ ] Ctrl+K opens search bar
|
||||
- [ ] Typing in search highlights matches
|
||||
- [ ] Up/Down in search mode cycles through matches
|
||||
- [ ] Escape returns to chat from any mode
|
||||
- [ ] Ctrl+T still cycles thinking levels
|
||||
- [ ] Auto-scroll follows new messages at bottom
|
||||
|
||||
**Step 3: Update task tracker**
|
||||
|
||||
Mark TUI-008, TUI-009, TUI-010, TUI-011 as ✅ done in `docs/TASKS-TUI_Improvements.md`
|
||||
|
||||
**Step 4: Commit and push**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "docs: mark Wave 2 tasks complete"
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
| Action | Path |
|
||||
| ------ | -------------------------------------------------- |
|
||||
| Create | `packages/cli/src/tui/hooks/use-viewport.ts` |
|
||||
| Create | `packages/cli/src/tui/hooks/use-conversations.ts` |
|
||||
| Create | `packages/cli/src/tui/hooks/use-app-mode.ts` |
|
||||
| Create | `packages/cli/src/tui/hooks/use-search.ts` |
|
||||
| Create | `packages/cli/src/tui/components/sidebar.tsx` |
|
||||
| Create | `packages/cli/src/tui/components/search-bar.tsx` |
|
||||
| Modify | `packages/cli/src/tui/app.tsx` |
|
||||
| Modify | `packages/cli/src/tui/hooks/use-socket.ts` |
|
||||
| Modify | `packages/cli/src/tui/components/message-list.tsx` |
|
||||
| Modify | `packages/cli/src/tui/components/bottom-bar.tsx` |
|
||||
| Modify | `docs/TASKS-TUI_Improvements.md` |
|
||||
@@ -0,0 +1,238 @@
|
||||
# Hermes-Mosaic Alignment Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Package Mosaic's mechanical coordination primitives as a native Hermes toolset so any Hermes profile gets mission management, task decomposition, handoff, and session continuity without depending on the Mosaic gateway or OpenClaw runtime.
|
||||
|
||||
**Architecture:** Extract the coordination logic from Mosaic's `packages/coord` (TypeScript, file-first) into a Hermes Python toolset that wraps the same file conventions. The Mosaic Stack repo remains the canonical upstream for the file formats (TASKS.md schema, mission.json schema, handoff packet schema). Hermes implements native Python tools that read/write those same files, plus tool-calls for churn detection and handoff generation that have no Mosaic equivalent today.
|
||||
|
||||
**Tech Stack:** Python (Hermes toolset), SQLite (Hermes Kanban), JSON + Markdown (Mosaic file conventions)
|
||||
|
||||
---
|
||||
|
||||
## Alignment Map
|
||||
|
||||
### What Mosaic has that Hermes needs
|
||||
|
||||
| Mosaic Component | What it does | Natural Hermes home | Why |
|
||||
| -------------------------------- | --------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/coord` (mission.ts) | Mission CRUD, session tracking, milestone state | **Hermes toolset: `mission`** | Mission state is session-scoped, not gateway-scoped. Hermes sessions already have identity, process tracking, and context windows. |
|
||||
| `packages/coord` (tasks-file.ts) | Parse/write TASKS.md tables | **Hermes toolset: `mission`** (same) | Hermes already reads/writes files. The TASKS.md parser is ~300 lines of pure string manipulation — trivial Python port. |
|
||||
| `packages/coord` (runner.ts) | Spawn claude/codex workers with continuation prompts | **Already covered by `delegate_task`** | Hermes delegate_task already does isolated subagent spawning with restricted toolsets. The runner's "find next task and build continuation prompt" logic moves into a tool-call. |
|
||||
| `packages/coord` (status.ts) | Mission health, task progress, next task | **Hermes toolset: `mission`** (same) | Status readout fits naturally as a tool-call. No gateway needed. |
|
||||
| `packages/prdy` | PRD generation wizard | **Hermes skill: `prdy`** | PRD generation is a prompt + template problem, not infrastructure. A Hermes skill with templates is the right fit. |
|
||||
| `plugins/mosaic-framework` | before_agent_start + subagent_spawning hooks | **Hermes system prompt injection** | Hermes already injects system context via skills and config. The framework preamble and worktree rules become standard Hermes skills loaded by the orchestrator profile. |
|
||||
| `plugins/macp` | OpenClaw ACP bridge (spawn codex/claude) | **Already covered by `delegate_task` + ACP** | Hermes already has ACP support and delegate_task. The MACP bridge is redundant when running natively in Hermes. |
|
||||
| Churn detection (planned) | Detect compaction loops, repeated tool calls, no progress | **Hermes middleware** | This needs to live inside Hermes's turn loop where it can observe tool-call patterns. Mosaic can't see this from outside. |
|
||||
| Handoff packet (planned) | Structured context summary for session rotation | **Hermes toolset: `mission`** | Handoff is a serialization of mission + session state. Hermes owns the session, so it should own the handoff. |
|
||||
|
||||
### What Hermes already has that replaces Mosaic infrastructure
|
||||
|
||||
| Mosaic concept | Hermes equivalent | Notes |
|
||||
| -------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Gateway (NestJS) | Hermes gateway | Hermes already has a gateway with WebSocket, Discord, Telegram, CLI. No need for a second one. |
|
||||
| Pi SDK agent runtime | Hermes agent loop | Hermes IS the agent runtime. OpenClaw's Pi SDK is a different runtime that Mosaic targets. |
|
||||
| MACP ACP bridge | `delegate_task` + ACP tools | Same capability, already native. |
|
||||
| Session identity | Hermes session IDs + process_registry | Hermes already tracks session identity, PIDs, and background processes. |
|
||||
| Task execution board | Hermes Kanban | Fully functional SQLite-backed Kanban with dispatcher, triage, events, comments. |
|
||||
| Worker spawning | Hermes dispatcher + cron | Kanban dispatcher + cron already handle this. |
|
||||
| Context injection | Hermes skills + system prompt | Skills are loaded at session start and injected into context. Exactly what mosaic-framework plugin does. |
|
||||
| File checkpoints | Hermes checkpoint_manager | Already tracks file mutations with shadow git. |
|
||||
|
||||
### What Mosaic keeps as its own entity
|
||||
|
||||
| Component | Why it stays in Mosaic |
|
||||
| --------------------- | --------------------------------------------------- |
|
||||
| `apps/gateway` | NestJS API surface — Mosaic's web platform offering |
|
||||
| `apps/web` | Next.js dashboard — Mosaic's UI offering |
|
||||
| `packages/types` | Shared TS contracts for Mosaic gateway plugins |
|
||||
| `packages/db` | Drizzle ORM + PG — Mosaic's data layer |
|
||||
| `packages/auth` | BetterAuth — Mosaic's auth system |
|
||||
| `packages/brain` | PG-backed data layer for Mosaic web app |
|
||||
| `packages/queue` | Valkey task queue for Mosaic gateway |
|
||||
| `plugins/discord` | OpenClaw Discord plugin |
|
||||
| `plugins/telegram` | OpenClaw Telegram plugin |
|
||||
| `packages/mosaic` CLI | The `mosaic` CLI — Mosaic's own command surface |
|
||||
|
||||
---
|
||||
|
||||
## Architecture: `mission` Toolset for Hermes
|
||||
|
||||
### New files under `/opt/hermes/tools/`
|
||||
|
||||
```
|
||||
mission_tools.py — Tool-call surface (mission_create, mission_status,
|
||||
mission_next_task, mission_update_task, mission_handoff,
|
||||
mission_resume)
|
||||
mission_state.py — State management (read/write mission.json, parse TASKS.md,
|
||||
parse MISSION-MANIFEST.md)
|
||||
mission_churn.py — Churn detection (tool-loop counter, compaction counter,
|
||||
progress scorer)
|
||||
mission_handoff.py — Handoff packet generation and loading
|
||||
```
|
||||
|
||||
### Tool-calls exposed to the agent
|
||||
|
||||
| Tool | What it does | When the agent calls it |
|
||||
| --------------------- | --------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| `mission_create` | Initialize mission.json + TASKS.md + MISSION-MANIFEST.md in a project dir | When starting a new mission |
|
||||
| `mission_status` | Read current mission state, milestone progress, next task, active session | At session start, or when checking progress |
|
||||
| `mission_next_task` | Find the next `not-started` task whose dependencies are met, return its full spec | When the agent needs work to do |
|
||||
| `mission_update_task` | Update a task row status in TASKS.md | When completing or blocking a task |
|
||||
| `mission_handoff` | Generate a handoff packet from current session context + mission state | Before session rotation or at session end |
|
||||
| `mission_resume` | Load a handoff packet and inject it as context for the new session | At session start after rotation |
|
||||
|
||||
### Toolset registration
|
||||
|
||||
The `mission` toolset follows the same pattern as `kanban`:
|
||||
|
||||
1. **Gating**: Tools are available when:
|
||||
- The profile has `mission` in its toolsets config, OR
|
||||
- A `HERMES_MISSION_DIR` env var is set (cron/dispatcher spawned workers)
|
||||
2. **File conventions**: The toolset reads/writes the same file formats as Mosaic `packages/coord`:
|
||||
- `.mosaic/orchestrator/mission.json` — mission state
|
||||
- `docs/TASKS.md` — task table
|
||||
- `docs/MISSION-MANIFEST.md` — mission manifest
|
||||
- `docs/scratchpads/<id>.md` — session scratchpad
|
||||
|
||||
3. **Kanban bridge**: Optional bidirectional sync between mission TASKS.md rows and Kanban task cards, so the dashboard sees mission tasks.
|
||||
|
||||
### Churn detection (middleware)
|
||||
|
||||
Churn detection lives in Hermes's turn loop, NOT as a tool-call. It observes:
|
||||
|
||||
- Repeated compaction events (context window pressure)
|
||||
- Identical tool-call sequences (loop detection)
|
||||
- No file state changes across N turns
|
||||
- Repeated permission denials
|
||||
|
||||
When churn score exceeds threshold:
|
||||
|
||||
1. `mission_handoff` is called automatically
|
||||
2. Session is rotated (fresh context window)
|
||||
3. `mission_resume` is called in the new session
|
||||
|
||||
This is new infrastructure that only Hermes can provide (Mosaic runs outside the agent loop).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Phase 1: Core state management (Python port of coord)
|
||||
|
||||
| Task | Files | Estimate |
|
||||
| -------------------------------------------------- | ----------------------------- | -------- |
|
||||
| 1.1 Port mission.json read/write to Python | `mission_state.py` | 2h |
|
||||
| 1.2 Port TASKS.md parser to Python | `mission_state.py` | 2h |
|
||||
| 1.3 Port MISSION-MANIFEST.md reader to Python | `mission_state.py` | 1h |
|
||||
| 1.4 Implement `mission_create` tool-call | `mission_tools.py` | 1h |
|
||||
| 1.5 Implement `mission_status` tool-call | `mission_tools.py` | 1h |
|
||||
| 1.6 Implement `mission_next_task` tool-call | `mission_tools.py` | 1h |
|
||||
| 1.7 Implement `mission_update_task` tool-call | `mission_tools.py` | 1h |
|
||||
| 1.8 Register `mission` toolset in Hermes registry | `tools/registry.py` | 30m |
|
||||
| 1.9 Add `mission` to orchestrator profile toolsets | `config.yaml` | 10m |
|
||||
| 1.10 Write unit tests for mission_state | `tests/test_mission_state.py` | 2h |
|
||||
| 1.11 Write unit tests for TASKS.md parser | `tests/test_tasks_parser.py` | 1h |
|
||||
|
||||
**Phase 1 estimate:** ~13h
|
||||
|
||||
### Phase 2: Handoff and session continuity
|
||||
|
||||
| Task | Files | Estimate |
|
||||
| ------------------------------------------------- | ---------------------------------------- | -------- |
|
||||
| 2.1 Define handoff packet schema (JSON) | `mission_handoff.py` | 1h |
|
||||
| 2.2 Implement `mission_handoff` tool-call | `mission_handoff.py`, `mission_tools.py` | 2h |
|
||||
| 2.3 Implement `mission_resume` tool-call | `mission_handoff.py`, `mission_tools.py` | 2h |
|
||||
| 2.4 Wire handoff into session start (auto-resume) | agent loop hook | 2h |
|
||||
| 2.5 Write tests for handoff round-trip | `tests/test_mission_handoff.py` | 1h |
|
||||
|
||||
**Phase 2 estimate:** ~8h
|
||||
|
||||
### Phase 3: Churn detection
|
||||
|
||||
| Task | Files | Estimate |
|
||||
| -------------------------------------------------------------- | ----------------------------- | -------- |
|
||||
| 3.1 Define churn signal weights and thresholds | `mission_churn.py` | 1h |
|
||||
| 3.2 Implement tool-loop detector (consecutive identical calls) | `mission_churn.py` | 2h |
|
||||
| 3.3 Implement compaction pressure detector | `mission_churn.py` | 1h |
|
||||
| 3.4 Implement progress scorer (file state delta) | `mission_churn.py` | 2h |
|
||||
| 3.5 Wire churn scoring into agent turn loop | agent loop middleware | 2h |
|
||||
| 3.6 Implement auto-rotation trigger | agent loop + handoff | 2h |
|
||||
| 3.7 Write tests for churn scoring | `tests/test_mission_churn.py` | 1h |
|
||||
|
||||
**Phase 3 estimate:** ~11h
|
||||
|
||||
### Phase 4: Kanban bridge + CLI surface
|
||||
|
||||
| Task | Files | Estimate |
|
||||
| ---------------------------------------------------- | ------------------------ | -------- |
|
||||
| 4.1 Implement TASKS.md → Kanban sync (one-way first) | `mission_kanban_sync.py` | 2h |
|
||||
| 4.2 Add `hermes mission` CLI subcommand | `mission_cli.py` | 2h |
|
||||
| 4.3 Add `hermes mission status` command | `mission_cli.py` | 1h |
|
||||
| 4.4 Add `hermes mission init` command | `mission_cli.py` | 1h |
|
||||
| 4.5 Add `hermes mission handoff` command | `mission_cli.py` | 1h |
|
||||
| 4.6 Add `hermes mission resume` command | `mission_cli.py` | 1h |
|
||||
|
||||
**Phase 4 estimate:** ~8h
|
||||
|
||||
---
|
||||
|
||||
## File Format Compatibility
|
||||
|
||||
The Python implementation MUST read and write the exact same file formats as Mosaic's TypeScript `packages/coord`. This means:
|
||||
|
||||
1. **mission.json** schema is identical to `Mission` type in `packages/coord/src/types.ts`
|
||||
2. **TASKS.md** table format is identical to what `packages/coord/src/tasks-file.ts` parses
|
||||
3. **MISSION-MANIFEST.md** is free-form markdown (no parser needed — just read the file)
|
||||
4. **Handoff packets** are a new JSON format defined in this toolset (Mosaic doesn't have them yet)
|
||||
|
||||
This way a project can use Hermes mission tools OR Mosaic `mosaic coord` commands interchangeably. The files are the contract.
|
||||
|
||||
---
|
||||
|
||||
## Relationship Diagram
|
||||
|
||||
```
|
||||
Mosaic Stack (TypeScript) Hermes Agent (Python)
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ packages/coord │ │ tools/mission_tools.py │
|
||||
│ ├─ mission.ts │◄──────►│ ├─ mission_state.py │
|
||||
│ ├─ tasks-file.ts │ same │ ├─ mission_handoff.py │
|
||||
│ ├─ status.ts │ files │ ├─ mission_churn.py │
|
||||
│ └─ runner.ts │ │ └─ mission_tools.py │
|
||||
│ │ │ │
|
||||
│ packages/prdy │ │ skills/prdy/ │
|
||||
│ └─ templates, wizard │◄──────►│ └─ SKILL.md + templates │
|
||||
│ │ │ │
|
||||
│ plugins/mosaic-framework│ │ skills/ (existing) │
|
||||
│ └─ context injection │◄──────►│ └─ kanban-orchestrator │
|
||||
│ │ │ + mosaic-coding-* │
|
||||
│ plugins/macp │ │ tools/delegate_task.py │
|
||||
│ └─ ACP bridge │◄──────►│ └─ already covers this │
|
||||
│ │ │ │
|
||||
│ (stays in Mosaic) │ │ tools/kanban_tools.py │
|
||||
│ apps/gateway │ │ └─ Hermes Kanban DB │
|
||||
│ apps/web │ │ │
|
||||
│ packages/db │ │ tools/cronjob_tools.py │
|
||||
│ packages/queue │ │ └─ already covers cron │
|
||||
└─────────────────────────┘ └─────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should the `mission` toolset ship with Hermes core, or as a plugin?**
|
||||
- Recommendation: ship as a **built-in toolset** (like `kanban`) since mission coordination is a core agent capability, not an optional integration. The file formats are stable and the code is small.
|
||||
|
||||
2. **Should churn detection be per-profile configurable?**
|
||||
- Recommendation: yes. Add `mission.churn_threshold` and `mission.churn_weights` to profile config.yaml. Default threshold = 5 consecutive no-progress turns.
|
||||
|
||||
3. **Should handoff packets live in the project dir or in Hermes home?**
|
||||
- Recommendation: **project dir** (`.mosaic/handoffs/<session-id>.json`). This keeps them version-controlled and accessible regardless of which agent runtime picks up the project.
|
||||
|
||||
4. **Bidirectional Kanban sync?**
|
||||
- Recommendation: **one-way first** (TASKS.md → Kanban). Bidirectional adds conflict resolution complexity. Ship one-way, add reverse sync in v2 if needed.
|
||||
|
||||
5. **PRD generation — skill or tool-call?**
|
||||
- Recommendation: **skill** (`prdy`). PRD generation is a prompt engineering problem with templates. Skills already handle this pattern perfectly.
|
||||
@@ -0,0 +1,236 @@
|
||||
# Mosaic Stack ↔ Hermes Coordination Resilience
|
||||
|
||||
> Purpose: document the self-healing coordination patterns that emerged while implementing the Hermes mission toolset, distress-card protocol, and auto-heal watchers, so the same mechanics can be reimplemented in Mosaic Stack or any similar agent platform.
|
||||
|
||||
## Summary
|
||||
|
||||
The coordination layer should be treated as a system of mechanical recovery loops rather than a single interactive agent session.
|
||||
|
||||
## SIBKISS operational summary
|
||||
|
||||
- mission on
|
||||
- heartbeat always
|
||||
- resume from packet
|
||||
- block with `[BLOCKED]`
|
||||
- reassign
|
||||
- keep tasks tiny
|
||||
- auto-heal dead workers
|
||||
|
||||
The design has four parts:
|
||||
|
||||
1. Atomic task decomposition — workers operate only within a small, explicit scope.
|
||||
2. Distress signaling — workers create a standardized `[BLOCKED]` card when they encounter a blocker outside their scope.
|
||||
3. Mechanical fallback — if the worker cannot phone home because of rate limits or dead context, a cron-style watcher synthesizes the distress card for them.
|
||||
4. Auto-heal / reassignment — stale workers are reaped, crash-loops are reset, and rate-limited work is reassigned to a different profile/provider.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Observed failure modes:
|
||||
|
||||
- Scope creep: a worker completes the target fix, then spends the rest of its budget chasing downstream cascade work.
|
||||
- Silent failure / dead worker: the worker PID is gone, but the task remains running or blocked.
|
||||
- Rate-limited worker: the worker is too constrained to create a help card itself, so it spins or fails without a clean handoff.
|
||||
|
||||
The answer is not to raise iteration caps or ask the worker to keep trying longer. The answer is to make the coordination layer self-healing and the work items atomic.
|
||||
|
||||
## Core workflow
|
||||
|
||||
### 1) Atomic task boundaries
|
||||
|
||||
Every task should have:
|
||||
|
||||
- one concern
|
||||
- explicit files/packages in scope
|
||||
- explicit files/packages out of scope
|
||||
- a maximum file count if possible
|
||||
- a stated expected iteration budget
|
||||
|
||||
When a worker discovers work outside scope, it must stop fixing it and hand off.
|
||||
|
||||
### 2) Worker-authored distress card
|
||||
|
||||
If the worker can still report status, it creates a card like:
|
||||
|
||||
- Title: `[BLOCKED] t_<source_id> <blocker_type>`
|
||||
- Assignee: `tuesday` / orchestrator role
|
||||
- Status: `ready`
|
||||
- Body: standardized distress template with source task, blocker type, completed work, cannot-touch scope, and needed action
|
||||
|
||||
The orchestrator receives the card, acts on it, and closes the loop.
|
||||
|
||||
## Routing rules
|
||||
|
||||
### Distress card routing
|
||||
|
||||
- Title: `[BLOCKED] t_<source_id> <blocker_type>`
|
||||
- Assignee: `tuesday` / orchestrator role
|
||||
- Status: `ready`
|
||||
- Body: standardized distress template with source task, blocker type, completed work, cannot-touch scope, and needed action
|
||||
- Source task stays linked to the distress card so the recovery trail is auditable
|
||||
|
||||
The orchestrator receives the card, acts on it, and closes the loop.
|
||||
|
||||
### 3) Mechanical fallback for rate-limited workers
|
||||
|
||||
If the worker is too rate-limited or unstable to create the distress card itself, a no-agent watcher must synthesize the card from the task row and failure metadata.
|
||||
|
||||
That watcher should:
|
||||
|
||||
- inspect running / blocked tasks
|
||||
- detect repeated 429 / 503 / overload errors
|
||||
- create the same standardized `[BLOCKED]` card on behalf of the worker
|
||||
- link the distress card to the source task
|
||||
- add a comment to the source task
|
||||
- allow the dispatcher to pick up the new card immediately
|
||||
|
||||
This is the key fix for the logic issue: the worker does not need to be able to phone home if the watcher can do it mechanically.
|
||||
|
||||
### 4) Auto-heal for dead workers
|
||||
|
||||
A separate no-agent watcher should:
|
||||
|
||||
- reap dead PIDs stuck in `running`
|
||||
- reset crash-loops whose failures are infrastructure-related
|
||||
- escalate tasks that have been reset too many times
|
||||
|
||||
This watcher prevents stale tasks from clogging the board and keeps the dispatch queue moving.
|
||||
|
||||
## Distress card contract
|
||||
|
||||
### Canonical title
|
||||
|
||||
```text
|
||||
[BLOCKED] t_<source_task_id> <blocker_type>
|
||||
```
|
||||
|
||||
### Canonical blocker types
|
||||
|
||||
- `scope_boundary`
|
||||
- `env_blocker`
|
||||
- `credential_failure`
|
||||
- `dependency`
|
||||
- `iteration_budget`
|
||||
- `rate_limited`
|
||||
|
||||
### Canonical body
|
||||
|
||||
```markdown
|
||||
## Distress Signal
|
||||
|
||||
- Blocked task: t_xxx
|
||||
- Worker: <profile_name>
|
||||
- Branch: <git_branch_name>
|
||||
- Workspace: <path>
|
||||
- Blocker type: <type>
|
||||
- Completed: <what was done>
|
||||
- Cannot touch: <out-of-scope packages/files>
|
||||
- Needs: <what the orchestrator should do>
|
||||
- State: committed | uncommitted | stashed(<stash_name>)
|
||||
|
||||
## Scope Guard
|
||||
|
||||
DO NOT touch: anything outside diagnosing and remediating the blocker described above
|
||||
Only fix: assign, split, reassign, or unblock the source task
|
||||
```
|
||||
|
||||
## Routing rules
|
||||
|
||||
### Distress card routing
|
||||
|
||||
- `[BLOCKED]` title prefix should bypass normal triage.
|
||||
- The card should go directly to the orchestration profile.
|
||||
- The orchestrator should start from a clean session each time.
|
||||
|
||||
### Rate-limit fallback
|
||||
|
||||
When the source task is rate-limited:
|
||||
|
||||
- do not keep retrying in the worker
|
||||
- let the watcher synthesize the distress card
|
||||
- have the orchestrator reassign the source task to a different profile/provider combo
|
||||
|
||||
### Provider fallback principle
|
||||
|
||||
Never reassign rate-limited work back to the same provider if the failure was provider pressure. Use a different provider when possible.
|
||||
|
||||
### Suggested fallback order
|
||||
|
||||
1. Keep the current task body and scope guards intact.
|
||||
2. Reassign to a different profile on a different provider.
|
||||
3. If that is impossible, reassign to a different profile on the same provider only for non-rate-limit blockers.
|
||||
4. If repeated failures continue, split the task into a narrower atomic card.
|
||||
|
||||
## Related recovery docs
|
||||
|
||||
- Mission packet recovery contract: `/opt/hermes/docs/mission-toolset-heartbeat.md`
|
||||
- Hermes mission implementation plan: `/opt/hermes/docs/plans/mission-toolset-implementation.md`
|
||||
- The same packet-first resume rule applies: inspect the latest packet before re-reading mission files.
|
||||
- New-session trigger: when a profile config changes, start a fresh session or `/reset` so the updated toolset is actually loaded.
|
||||
|
||||
## Watchers to implement
|
||||
|
||||
### Auto-heal watcher
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- reap stale workers
|
||||
- reset dead-PID crash loops
|
||||
- track reset counts
|
||||
- escalate after repeated resets
|
||||
|
||||
### Distress synthesizer watcher
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- detect rate-limited / stuck workers
|
||||
- create `[BLOCKED]` cards mechanically
|
||||
- link the card to the source task
|
||||
- leave a comment for traceability
|
||||
|
||||
### Iteration-budget watcher
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- detect long-running tasks and repeated failure patterns
|
||||
- recommend splits when a task is clearly over-scoped
|
||||
- report tasks that need human review after multiple resets
|
||||
|
||||
## Operational principle
|
||||
|
||||
If a task cannot cleanly finish within its atomic scope, the right response is to surface a smaller coordination problem, not to keep burning context.
|
||||
|
||||
This is what makes the system robust across compaction, rate limits, and dead workers.
|
||||
|
||||
## Suggested implementation order
|
||||
|
||||
1. Atomic task metadata in task bodies
|
||||
2. Worker-authored distress card protocol
|
||||
3. Mechanical distress synthesizer watcher
|
||||
4. Auto-heal watcher for dead workers
|
||||
5. Orchestrator routing rules for `[BLOCKED]`
|
||||
6. Rate-limit fallback / model reassignment table
|
||||
|
||||
## Where this fits in Hermes
|
||||
|
||||
- Kanban = durable work graph and status engine
|
||||
- Watchers = mechanical healing and distress synthesis
|
||||
- Orchestrator = split / reassign / unblock decision-maker
|
||||
- Workers = execution inside atomic task boundaries
|
||||
|
||||
## Where this fits in Mosaic Stack
|
||||
|
||||
- PRD / coordination infra should encode the same patterns
|
||||
- Mosaic can use the same distress-card contract and watcher logic
|
||||
- The coordination model should be runtime-agnostic: any agent system can use it if it can write a task card and react to a ready queue
|
||||
|
||||
## Cross-project takeaway
|
||||
|
||||
The important pattern is not the specific tool names. It is the mechanical feedback loop:
|
||||
|
||||
- detect failure without requiring the failing worker to succeed
|
||||
- create a standardized help artifact
|
||||
- route that artifact to a fresh orchestrator context
|
||||
- repair the assignment graph
|
||||
- continue the mission
|
||||
|
||||
That pattern is reusable anywhere.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Legacy plans and deferred design stubs
|
||||
|
||||
> **Status:** Historical planning archive. These files preserve prior proposals and implementation approaches; they are not proof of shipped behavior or authority to run commands.
|
||||
|
||||
The records below moved byte-identically from migration quarantine on 2026-08-10. Validate every claim against current source, tests, configuration, and safety policy before reuse.
|
||||
|
||||
## Implementation plans
|
||||
|
||||
- [Gateway security hardening](2026-03-13-gateway-security-hardening.md)
|
||||
- [Agent platform architecture](2026-03-15-agent-platform-architecture.md)
|
||||
- [Wave 2 TUI layout and navigation](2026-03-15-wave2-tui-layout-navigation.md)
|
||||
- [Hermes–Mosaic alignment](2026-05-06-hermes-mosaic-alignment.md)
|
||||
- [Coordination resilience](2026-05-07-coordination-resilience.md)
|
||||
- [Gateway token recovery](gateway-token-recovery.md)
|
||||
|
||||
## Setup record
|
||||
|
||||
- [Authentik SSO setup](authentik-sso-setup.md) — superseded for current administration by the canonical [SSO provider guide](../../../ADMIN-GUIDE/security/sso-providers.md).
|
||||
|
||||
## Explicitly deferred stubs
|
||||
|
||||
- [Chroot agent sandboxing](chroot-sandboxing.md)
|
||||
- [Gatekeeper service](gatekeeper-service.md)
|
||||
- [Task queue unification](task-queue-unification.md)
|
||||
|
||||
## Exclusions
|
||||
|
||||
The Agent Reflection PRD remains in quarantine because a live MACP test names its intended canonical path. The WebUI/Fleet Claude bridge draft remains authority-gated and coupled to Fleet decisions. Neither was moved in this archival slice.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Authentik SSO Setup
|
||||
|
||||
## Create the Authentik application
|
||||
|
||||
1. In Authentik, create an OAuth2/OpenID Provider.
|
||||
2. Create an Application and link it to that provider.
|
||||
3. Copy the generated client ID and client secret.
|
||||
|
||||
## Required environment variables
|
||||
|
||||
Set these values for the gateway/auth runtime:
|
||||
|
||||
```bash
|
||||
AUTHENTIK_CLIENT_ID=your-client-id
|
||||
AUTHENTIK_CLIENT_SECRET=your-client-secret
|
||||
AUTHENTIK_ISSUER=https://authentik.example.com
|
||||
```
|
||||
|
||||
`AUTHENTIK_ISSUER` should be the Authentik base URL, for example `https://authentik.example.com`.
|
||||
|
||||
## Redirect URI
|
||||
|
||||
Configure this redirect URI in the Authentik provider/application:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/callback/authentik
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
https://mosaic.example.com/api/auth/callback/authentik
|
||||
```
|
||||
|
||||
## Test the flow
|
||||
|
||||
1. Start the gateway with `BETTER_AUTH_URL` and the Authentik environment variables set.
|
||||
2. Open the Mosaic login flow and choose the Authentik provider.
|
||||
3. Complete the Authentik login.
|
||||
4. Confirm the browser returns to Mosaic and a session is created successfully.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Chroot Agent Sandboxing — Process Isolation for Agent Tool Execution
|
||||
|
||||
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Phase 7 Workspaces → Chroot Agent Sandboxing).
|
||||
> Implement after Workspaces (P8-015) is complete. Requires workspace directory structure and `WorkspaceService` to be operational.
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Packages:** `apps/gateway`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Agent sessions can use file, git, and shell tools. Path validation in tools is defense-in-depth but insufficient alone — an agent with shell access can run `cat /opt/mosaic/.workspaces/other_user/...` and bypass gateway RBAC.
|
||||
|
||||
Chroot provides OS-level enforcement: tool processes literally cannot see outside their workspace directory.
|
||||
|
||||
---
|
||||
|
||||
## Design (Sweet Spot)
|
||||
|
||||
Chroot strikes the balance between full container isolation (too heavy per session) and path validation only (escape-prone):
|
||||
|
||||
- Gateway spawns tool processes inside a chroot rooted at the session's `sandboxDir`
|
||||
- Requires `CAP_SYS_CHROOT` capability on the gateway process (not full root)
|
||||
- Chroot environment provisioned by `WorkspaceService` on workspace creation (minimal deps: git, shell utils, language runtimes as needed)
|
||||
- Alternative for Docker deployments: Linux `unshare` namespaces (lighter, no chroot env setup)
|
||||
|
||||
---
|
||||
|
||||
## Scope (To Be Designed)
|
||||
|
||||
- [ ] Chroot environment provisioning — `WorkspaceService.provisionChroot(workspacePath)` on project creation
|
||||
- [ ] Minimal chroot deps — identify required binaries/libs per tool type (file: none; git: git binary; shell: bash, common utils)
|
||||
- [ ] Gateway capability — document `CAP_SYS_CHROOT` requirement; Dockerfile and docker-compose.yml changes
|
||||
- [ ] Tool process spawning — modify `createShellTools`, `createFileTools`, `createGitTools` to spawn via chroot wrapper
|
||||
- [ ] Docker alternative — `unshare --mount --pid --user` namespace wrapper as fallback for environments without chroot capability
|
||||
- [ ] Defense-in-depth layering — chroot + path validation both active; neither alone is sufficient
|
||||
- [ ] Chroot cleanup — integrate with `SessionGCService` / workspace deletion
|
||||
- [ ] AppArmor/SELinux profiles (v2) — restrict gateway process file access patterns for multi-tenant hardening
|
||||
|
||||
---
|
||||
|
||||
## Security Constraints
|
||||
|
||||
- What lives **inside** the chroot (agent-accessible): workspace files, git repo, language runtimes
|
||||
- What lives **outside** the chroot (gateway-only, never agent-accessible): Valkey connection, PG connection, other users' workspaces, gateway config, OTEL endpoint, credentials
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspaces (P8-015) — chroot is rooted at workspace directory; workspace must exist first
|
||||
- Tool hardening (P8-016) — path validation stays active as defense-in-depth alongside chroot
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Chroot Agent Sandboxing" section
|
||||
- Current tool implementations: `apps/gateway/src/agent/tools/`
|
||||
@@ -0,0 +1,53 @@
|
||||
# Gatekeeper Service — PR Review, Quality Gates & Merge Authority
|
||||
|
||||
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Phase 7 Workspaces).
|
||||
> Implement after Workspaces (P8-015) is complete and the workspace/git infrastructure is operational.
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Packages:** `apps/gateway`, `packages/types`, `packages/agent`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Project agents create PRs but cannot review or merge their own work. A separate, isolated agent service with read-only code access and quality gate enforcement is needed to act as the authoritative merge authority.
|
||||
|
||||
The Gatekeeper existed in the old Mosaic codebase and must be ported/redesigned for mosaic-mono-v1.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Constraints
|
||||
|
||||
- **Isolated trust boundary** — project agents cannot invoke Gatekeeper directly; it listens for PR events from the git provider
|
||||
- **`isSystem: true`** — system agent, not editable by users
|
||||
- **Read-only code access** — reads diffs and runs checks; cannot commit or push
|
||||
- **Quality gates required before merge** — lint, typecheck, test results must pass
|
||||
- **Cannot self-approve** — the agent that authored the PR cannot be the Gatekeeper for that PR
|
||||
|
||||
---
|
||||
|
||||
## Scope (To Be Designed)
|
||||
|
||||
- [ ] Gatekeeper agent bootstrap — system agent config, tool set, prompt engineering
|
||||
- [ ] PR event listener — Gitea/GitHub webhook integration (PR opened/updated/ready)
|
||||
- [ ] Quality gate runner — trigger CI checks, poll for results, enforce pass criteria
|
||||
- [ ] Review generation — LLM-driven code review comment generation
|
||||
- [ ] Merge execution — approve + merge when gates pass; reject with comments when they fail
|
||||
- [ ] Configurable strictness — per-project required checks, review depth
|
||||
- [ ] Trust boundary enforcement — gateway rejects Gatekeeper tool calls that exceed read-only scope
|
||||
- [ ] Audit trail — OTEL spans for all Gatekeeper decisions (approve/reject/merge)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspaces (P8-015) — Gatekeeper needs project workspace layout to locate code
|
||||
- Git provider API tools — PR creation/review/merge API (Gitea/GitHub/GitLab)
|
||||
- CI/CD tool integration — Woodpecker pipeline status polling
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Gatekeeper Service" section
|
||||
- Workspace RBAC and agent trust model: same document → "RBAC & Filesystem Security"
|
||||
@@ -0,0 +1,193 @@
|
||||
# Gateway Admin Token Recovery — Implementation Plan
|
||||
|
||||
**Mission:** `cli-unification-20260404`
|
||||
**Task:** `CU-03-01` (planning only — no runtime code changes)
|
||||
**Status:** Design locked (Session 1) — BetterAuth cookie-based recovery
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The gateway installer strands operators when the admin user exists but the admin
|
||||
API token is missing. Concrete trigger:
|
||||
|
||||
- `~/.config/mosaic/gateway/meta.json` was deleted / regenerated.
|
||||
- The installer was re-run after a previous successful bootstrap.
|
||||
|
||||
Flow today (`packages/mosaic/src/commands/gateway/install.ts:375-400`):
|
||||
|
||||
1. `bootstrapFirstUser` hits `GET /api/bootstrap/status`.
|
||||
2. Server returns `needsSetup: false` because `users` count > 0.
|
||||
3. Installer logs `Admin user already exists — skipping setup. (No admin token on file — sign in via the web UI to manage tokens.)` and returns.
|
||||
4. The operator now has:
|
||||
- No token in `meta.json`.
|
||||
- No CLI path to mint a new one (`mosaic gateway <anything>` that needs the token fails).
|
||||
- `POST /api/bootstrap/setup` locked out — it only runs when `users` count is zero (`apps/gateway/src/admin/bootstrap.controller.ts:34-37`).
|
||||
- `POST /api/admin/tokens` gated by `AdminGuard` — requires either a bearer token (which they don't have) or a BetterAuth session (which they don't have in the CLI).
|
||||
|
||||
Dead end. The web UI is the only escape hatch today, and for headless installs even that may be inaccessible.
|
||||
|
||||
## 2. Design Summary
|
||||
|
||||
The BetterAuth session cookie is the authority. The operator runs
|
||||
`mosaic gateway login` to sign in with email/password, which persists a session
|
||||
cookie via `saveSession` (reusing `packages/mosaic/src/auth.ts`). With a valid
|
||||
session, `mosaic gateway config recover-token` (stranded-operator entry point)
|
||||
and `mosaic gateway config rotate-token` call the existing authenticated admin
|
||||
endpoint `POST /api/admin/tokens` using the cookie, then persist the returned
|
||||
plaintext to `meta.json` via `writeMeta`. **No new server endpoints are
|
||||
required** — `AdminGuard` already accepts BetterAuth session cookies via its
|
||||
`validateSession` path (`apps/gateway/src/admin/admin.guard.ts:90-120`).
|
||||
|
||||
## 3. Surface Contract
|
||||
|
||||
### 3.1 Server — no changes required
|
||||
|
||||
| Endpoint | Status | Notes |
|
||||
| ------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `POST /api/admin/tokens` | **Reuse as-is** | `admin-tokens.controller.ts:46-72`. Returns `{ id, label, scope, expiresAt, lastUsedAt, createdAt, plaintext }`. |
|
||||
| `GET /api/admin/tokens` | **Reuse** | Useful for `mosaic gateway config tokens list` follow-on (out of scope for CU-03-01, but trivial once auth path exists). |
|
||||
| `DELETE /api/admin/tokens/:id` | **Reuse** | Used by rotate flow for optional old-token revocation. |
|
||||
| `POST /api/bootstrap/setup` | **Unchanged** | Remains first-user-only; not part of recovery. |
|
||||
|
||||
`AdminGuard.validateSession` takes BetterAuth cookies from `request.raw.headers`
|
||||
via `fromNodeHeaders` and calls `auth.api.getSession({ headers })`. It also
|
||||
enforces `role === 'admin'`. This is exactly the path the CLI will hit with
|
||||
`Cookie: better-auth.session_token=...`.
|
||||
|
||||
**Confirmed feasible** during CU-03-01 investigation.
|
||||
|
||||
### 3.2 `mosaic gateway login`
|
||||
|
||||
Thin wrapper over the existing top-level `mosaic login`
|
||||
(`packages/mosaic/src/cli.ts:42-76`) with gateway-specific defaults pulled from
|
||||
`readMeta()`.
|
||||
|
||||
| Aspect | Behavior |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Default gateway URL | `http://${meta.host}:${meta.port}` from `readMeta()`, fallback `http://localhost:14242`. |
|
||||
| Flow | Prompt email + password -> `signIn()` -> `saveSession()`. |
|
||||
| Persistence | `~/.mosaic/session.json` via existing `saveSession` (7-day expiry). |
|
||||
| Decision | **Thin wrapper**, not alias. Rationale: defaults differ (reads `meta.json`), and discoverability under `mosaic gateway --help`. |
|
||||
| Implementation | Share the sign-in logic by extracting a small `runLogin(gatewayUrl, email?, password?)` helper; both commands call it. |
|
||||
|
||||
### 3.3 `mosaic gateway config rotate-token`
|
||||
|
||||
| Aspect | Behavior |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Precondition | Valid session (via `loadSession` + `validateSession`). On failure, print: "Not signed in — run `mosaic gateway login`" and exit non-zero. |
|
||||
| Request | `POST ${gatewayUrl}/api/admin/tokens` with header `Cookie: <session>`, body `{ label: "CLI token (rotated YYYY-MM-DD)" }`. |
|
||||
| On success | Read meta via `readMeta()`, set `meta.adminToken = plaintext`, `writeMeta(meta)`. Print the token banner (reuse `printAdminTokenBanner` shape). |
|
||||
| Old token | **Optional `--revoke-old`** flag. When set and a previous `meta.adminToken` existed, call `DELETE /api/admin/tokens/:id` after rotation. Requires listing first to find the id; punt to CU-03-02 decision. Document as nice-to-have. |
|
||||
| Exit codes | `0` success; `1` network error; `2` auth error; `3` server rejection. |
|
||||
|
||||
### 3.4 `mosaic gateway config recover-token`
|
||||
|
||||
Superset of `rotate-token` with an inline login nudge — the "stranded operator"
|
||||
entry point.
|
||||
|
||||
| Step | Action |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | `readMeta()` — derive gateway URL. If meta is missing entirely, fall back to `--gateway` flag or default. |
|
||||
| 2 | `loadSession(gatewayUrl)` then `validateSession`. If either fails, prompt inline: email + password -> `signIn` -> `saveSession`. |
|
||||
| 3 | `POST /api/admin/tokens` with cookie, label `"Recovered via CLI YYYY-MM-DDTHH:mm"`. |
|
||||
| 4 | Persist plaintext to `meta.json` via `writeMeta`. |
|
||||
| 5 | Print the token banner and next-steps hints (e.g. `mosaic gateway status`). |
|
||||
| 6 | Exit `0`. |
|
||||
|
||||
Key property: this command is **runnable with nothing but email+password in hand**.
|
||||
It assumes the gateway is up but assumes no prior CLI session state.
|
||||
|
||||
### 3.5 File touch list (for CU-03-02..05 execution)
|
||||
|
||||
| File | Change |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `packages/mosaic/src/commands/gateway.ts` | Register `login`, `config recover-token`, `config rotate-token` subcommands under `gw`. |
|
||||
| `packages/mosaic/src/commands/gateway/config.ts` | Add `runRecoverToken`, `runRotateToken` handlers; export from module. |
|
||||
| `packages/mosaic/src/commands/gateway/login.ts` (new) | Thin wrapper calling shared `runLogin` helper with meta-derived default URL. |
|
||||
| `packages/mosaic/src/auth.ts` | No change expected. Possibly export a `requireSession(gatewayUrl)` helper (reuse pattern). |
|
||||
| `packages/mosaic/src/commands/gateway/install.ts` | `bootstrapFirstUser` branch: "user exists, no token" -> offer recovery (see Section 4). |
|
||||
|
||||
## 4. Installer Fix (CU-03-06 preview)
|
||||
|
||||
Current stranding point is `install.ts:388-395`. The fix:
|
||||
|
||||
```
|
||||
if (!status.needsSetup) {
|
||||
if (meta.adminToken) {
|
||||
// unchanged — happy path
|
||||
} else {
|
||||
// NEW: prompt "Admin exists but no token on file. Recover now? [Y/n]"
|
||||
// If yes -> call runRecoverToken(gatewayUrl) inline (interactive):
|
||||
// - prompt email + password
|
||||
// - signIn -> saveSession
|
||||
// - POST /api/admin/tokens
|
||||
// - writeMeta(meta) with returned plaintext
|
||||
// - print banner
|
||||
// If no -> print the current stranded message but include:
|
||||
// "Run `mosaic gateway config recover-token` when ready."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Shape notes (actual code lands in CU-03-06):
|
||||
|
||||
- Extract the recovery body so it can be called **both** from the standalone
|
||||
command and from `bootstrapFirstUser` without duplicating prompts.
|
||||
- Reuse the same `rl` readline interface already open in `bootstrapFirstUser`
|
||||
for the inline prompts.
|
||||
- Preserve non-interactive behavior: if `process.stdin.isTTY` is false, skip the
|
||||
prompt and emit the "run recover-token" hint only.
|
||||
|
||||
## 5. Test Strategy (CU-03-07 scope)
|
||||
|
||||
### 5.1 Happy paths
|
||||
|
||||
| Command | Scenario | Expected |
|
||||
| ------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- |
|
||||
| `mosaic gateway login` | Valid creds | `session.json` written, 7-day expiry, exit 0 |
|
||||
| `mosaic gateway config rotate-token` | Valid session, server reachable | `meta.json` updated, banner printed, new token usable |
|
||||
| `mosaic gateway config recover-token` | No session, valid creds, server reachable | Prompts for creds, writes session + meta, exit 0 |
|
||||
| Installer inline recovery | Re-run after `meta.json` wipe, operator says yes | Meta restored, banner printed, no manual CLI step needed |
|
||||
|
||||
### 5.2 Error paths (must all produce actionable messages and non-zero exit)
|
||||
|
||||
| Failure | Expected handling |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Invalid email/password | BetterAuth 401 surfaced as "Sign-in failed: <server message>", exit 2 |
|
||||
| Expired stored session | Recover command silently re-prompts; rotate command exits 2 with "run login" hint |
|
||||
| Gateway down / connection refused | "Could not reach gateway at <url>" exit 1 |
|
||||
| Server rejects token creation | Print status + body excerpt, exit 3 |
|
||||
| Meta file missing (recover) | Fall back to `--gateway` flag or default; warn that meta will be created |
|
||||
| Non-admin user | `AdminGuard` 403 surfaced as "User is not an admin", exit 2 |
|
||||
|
||||
### 5.3 Integration test (recommended)
|
||||
|
||||
Spin up gateway in test harness, create admin user via `/api/bootstrap/setup`,
|
||||
wipe `meta.json`, invoke `mosaic gateway config recover-token` programmatically,
|
||||
assert new `meta.adminToken` works against `GET /api/admin/tokens`.
|
||||
|
||||
## 6. Risks & Open Questions
|
||||
|
||||
| # | Item | Severity | Mitigation |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | `AdminGuard.validateSession` calls `getSession` with `fromNodeHeaders(request.raw.headers)`. CLI sends `Cookie:` header only. Confirm BetterAuth reads from `Cookie`, not `Set-Cookie`. | Low | Confirmed — `mosaic login` + `mosaic tui` already use this flow successfully (`cli.ts:137-181`). |
|
||||
| 2 | Session cookie local expiry (7d) vs BetterAuth server-side expiry may drift. | Low | `validateSession` hits `get-session`; handle 401 by re-prompting. |
|
||||
| 3 | Label collision / unbounded token growth if operators run `recover-token` repeatedly. | Low | Include ISO timestamp in label. Optional `--revoke-old` in CU-03-02. Add `tokens list/prune` later. |
|
||||
| 4 | `mosaic login` exists at top level and `mosaic gateway login` is a wrapper — risk of confusion. | Low | Document that `gateway login` is the preferred entry for gateway operators; top-level stays for compatibility. |
|
||||
| 5 | `meta.json` write is not atomic. Crash between token creation and `writeMeta` leaves an orphan token server-side with no plaintext on disk. | Medium | Accept for now — re-running `recover-token` mints a fresh token. Document as known limitation. |
|
||||
| 6 | Non-TTY installer runs (CI, headless provisioners) cannot prompt for creds interactively. | Medium | Installer inline recovery must skip prompt when `!process.stdin.isTTY`; emit the recover-token hint. |
|
||||
| 7 | If `BETTER_AUTH_SECRET` rotates between login and recover, the session cookie is invalid — user must re-login. Acceptable but surface a clear error. | Low | Error handler maps 401 on recover -> "Session invalid; re-run `mosaic gateway login`". |
|
||||
| 8 | No MFA today. When MFA lands, BetterAuth sign-in will return a challenge, not a cookie — recovery UX will need a second prompt step. | Future | Out of scope for this mission. Flag for future CLI work. |
|
||||
|
||||
## 7. Downstream Task Hooks
|
||||
|
||||
| Task | Scope |
|
||||
| -------- | -------------------------------------------------------------------------- |
|
||||
| CU-03-02 | Implement `mosaic gateway login` wrapper + shared `runLogin` extraction. |
|
||||
| CU-03-03 | Implement `mosaic gateway config rotate-token`. |
|
||||
| CU-03-04 | Implement `mosaic gateway config recover-token`. |
|
||||
| CU-03-05 | Wire commands into `gateway.ts` registration, update `--help` copy. |
|
||||
| CU-03-06 | Installer inline recovery hook in `bootstrapFirstUser`. |
|
||||
| CU-03-07 | Tests per Section 5. |
|
||||
| CU-03-08 | Docs: update gateway install README + operator runbook with recovery flow. |
|
||||
@@ -0,0 +1,60 @@
|
||||
# Task Queue Unification — @mosaicstack/queue as Unified Orchestration Layer
|
||||
|
||||
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Task Queue & Orchestration section).
|
||||
> Implement after Workspaces (P8-015) is complete. Requires workspace file structure to be in place.
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Packages:** `packages/queue`, `packages/coord`, `packages/db`, `apps/gateway`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Two disconnected task systems exist:
|
||||
|
||||
1. **`@mosaicstack/coord`** — file-based missions (`mission.json`, `TASKS.md`), file locks, subprocess spawning. Single-machine orchestrator pattern.
|
||||
2. **PG tables** (`tasks`, `mission_tasks`, `missions`) — DB-backed CRUD, REST API, Brain repos.
|
||||
|
||||
An agent using `coord_mission_status` gets file data. The dashboard shows DB data. They are never in sync.
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
|
||||
`@mosaicstack/queue` becomes the unified task orchestration service bridging PG, workspace files, and Valkey:
|
||||
|
||||
- DB is source of truth for structured state (status, assignees, timestamps)
|
||||
- Workspace files (`TASKS.md`, PRDs) are working copies for agent interaction
|
||||
- Valkey handles real-time assignment queues and agent claim locks
|
||||
- Flatfile fallback for no-DB single-machine deployments (preserves `@mosaicstack/coord` pattern)
|
||||
|
||||
---
|
||||
|
||||
## Scope (To Be Designed)
|
||||
|
||||
- [ ] `@mosaicstack/queue` refactor — elevate from ioredis primitive to task orchestration service
|
||||
- [ ] DB ↔ file sync layer — writes to PG propagate to `TASKS.md`; file edits by agents sync back
|
||||
- [ ] Task assignment queue — Valkey-backed RPUSH/BLPOP for agent task claiming
|
||||
- [ ] Agent claim locks — `mosaic:queue:project:{id}:lock:{taskId}` with TTL
|
||||
- [ ] `@mosaicstack/coord` consolidation — file-based ops ported into queue service; `@mosaicstack/coord` becomes thin adapter or deprecated
|
||||
- [ ] Flatfile fallback — queue service writes JSON manifests when PG unavailable
|
||||
- [ ] Status pub/sub — real-time task status updates via Valkey pub/sub
|
||||
- [ ] Dependency resolution — block task assignment until dependencies are met
|
||||
- [ ] Orchestrator monitor — gateway process watches task queue, assigns next based on dependency graph
|
||||
- [ ] API surface — queue service exposes typed interface used by agents, gateway, and CLI
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspaces (P8-015) — file sync targets the workspace directory structure
|
||||
- Teams architecture (P8-007) — project ownership determines queue namespacing
|
||||
- DB schema stable — task/mission tables must not change mid-unification
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Task Queue & Orchestration" section
|
||||
- Current `@mosaicstack/coord` implementation: `packages/coord/src/`
|
||||
- Current `@mosaicstack/queue` implementation: `packages/queue/src/`
|
||||
Reference in New Issue
Block a user