Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Contributing

Use Node.js 24 LTS, npm, and the BB CLI. Release preparation uses Node 24.20.0;
older Node versions have not been verified. The public SDK is locked at 0.4.34.
older Node versions have not been verified. The public SDK is locked at 0.6.132.

```sh
npm ci
Expand Down
2 changes: 1 addition & 1 deletion PLUGIN_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ reopen its notes from the dashboard whenever needed.

## Requirements

Requires BB 0.40 or newer; BB 0.46.0 is recommended. Ollama Fleet 0.6.0 or newer and Redteam's public observability RPC
Requires BB 0.47 or newer. Ollama Fleet 0.6.0 or newer and Redteam's public observability RPC
are optional integrations. Missing integrations leave native BB observation
available. Mobile layouts and reduced-motion preferences are supported.
Hidden reasoning is excluded. System/push notifications are unavailable in
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ bb plugin build
bb plugin install .
```

Neither Fleet nor Redteam is required. Declared compatibility is BB >=0.40 and
SDK >=0.4.21; the development SDK is pinned to 0.4.34. See the honest
Neither Fleet nor Redteam is required. Declared compatibility is BB >=0.47 and
SDK >=0.6.73; the development SDK is pinned to 0.6.132. See the honest
[compatibility matrix](RELEASE_AUDIT.md), including historical runtime evidence.
For the prepared, unpublished 0.7.0 source, see [compatibility and upgrade notes](docs/upgrade-0.7.0.md).

Expand Down
8 changes: 5 additions & 3 deletions app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// surface-agnostic: it takes a rootThreadId, so a future entry surface
// (header action, nav panel, message action) can mount it unchanged.
import { definePluginApp } from "@get-bb/plugin-sdk/app";
import type { PluginNavPanelProps, PluginThreadPanelProps } from "@get-bb/plugin-sdk";
import type { ExperimentalThreadActionTabProps, PluginNavPanelProps } from "@get-bb/plugin-sdk";
import { Board } from "@/components/board/board";
import { GlobalDashboard } from "@/components/global/global-dashboard";
import "./app.css";
Expand All @@ -18,7 +18,7 @@ import "./app.css";
* persisted `params` (used when another surface opens this panel scoped to a
* different root: {"rootThreadId": "thr_..."}).
*/
function BoardPanel({ threadId, params }: PluginThreadPanelProps) {
function BoardPanel({ threadId, params }: ExperimentalThreadActionTabProps) {
const fromParams =
params !== null &&
typeof params === "object" &&
Expand All @@ -42,7 +42,9 @@ export default definePluginApp((app) => {
path: "operations",
component: GlobalDashboardPage,
});
app.slots.threadPanelAction({
app.slots.experimental_tab({
context: { kind: "thread" },
availability: "action",
id: "agent-board",
title: "Agent Board",
icon: "ListTodo",
Expand Down
19 changes: 18 additions & 1 deletion contract/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export const boardRootSchema = z.object({
goal: z
.object({
objective: z.string(),
status: z.enum(["active", "paused", "budgetLimited", "complete"]),
status: z.enum(["active", "paused", "blocked", "usageLimited", "budgetLimited", "complete"]),
tokensUsed: z.number(),
tokenBudget: z.number().nullable(),
timeUsedSeconds: z.number(),
Expand Down Expand Up @@ -395,6 +395,21 @@ export const boardChangedPayloadSchema = z.object({
global: z.boolean().optional(),
});

/** Query tags: every board read, one root's board read, and the global page read. */
export const BOARD_TAG = "board";
export const GLOBAL_DASHBOARD_TAG = "global-dashboard";
export function boardTag(rootThreadId: string): string {
return `board:${rootThreadId}`;
}

/** Tags made stale by one watcher invalidation payload (same rules the realtime listeners apply). */
export function boardChangedTags(payload: z.infer<typeof boardChangedPayloadSchema>): string[] {
if (payload.roots.length === 0) {
return payload.global === true ? [GLOBAL_DASHBOARD_TAG] : [BOARD_TAG, GLOBAL_DASHBOARD_TAG];
}
return [...payload.roots.map(boardTag), ...(payload.global === true ? [GLOBAL_DASHBOARD_TAG] : [])];
}

export const rpcContract = {
release_announcement: {
input: z.object({}),
Expand All @@ -412,6 +427,7 @@ export const rpcContract = {
board_snapshot: {
input: z.object({ threadId: z.string().min(1), fresh: z.boolean().optional() }),
output: boardSnapshotSchema,
tags: (input: { threadId: string }) => [BOARD_TAG, boardTag(input.threadId)],
},
/** Bounded recent-activity tail for one thread (details expansion). */
board_activity: {
Expand All @@ -430,6 +446,7 @@ export const rpcContract = {
global_dashboard: {
input: z.object({ fresh: z.boolean().optional() }),
output: globalDashboardSnapshotSchema,
tags: () => [GLOBAL_DASHBOARD_TAG],
},
/**
* Presentation-only: hide one attention item from the global dashboard.
Expand Down
18 changes: 11 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ Redteam timelines reuse the same public observability snapshot and generic UI. O

## Realtime and polling

BB changes use the existing public thread lifecycle/entity stream, a 400 ms coalescing watcher, and the plugin's `agent-board-changed` frontend signal.
BB changes use the existing public thread lifecycle/entity stream and a 400 ms coalescing watcher. The board and global page read their snapshots with `experimental_useRpcQuery`; the watcher invalidates the `board:<rootThreadId>` and `global-dashboard` tags with `bb.rpc.experimental_invalidate`, and the host refetches open views and refreshes them after reconnects. The `agent-board-changed` realtime signal remains for the Timeline dialog and the global page's monitoring health line.

SDK 0.4.21 has no supported cross-plugin realtime subscription. BB changes therefore opportunistically re-check Redteam. After a snapshot discovers a queued/running Redteam scan, a server-side fallback probes that project/global snapshot every 3 seconds. Requests share the Redteam source's inflight guard and 1.5-second TTL and never overlap. Polling stops when observed scans are terminal or the watched lease expires. While the global page is mounted, one slow 20-second snapshot refresh discovers brand-new Redteam work that has no BB event; unmounting clears that timer. BB thread changes remain event-driven.
A board or global page counts as open while a client watches its read: `bb.rpc.experimental_whileWatched` starts the watcher, Redteam and Fleet bookkeeping for that read and aborts it when the last client leaves or hides the tab. A thread the board has not seen yet (for example a grandchild created between snapshots) is matched to its root with one batched `experimental_listAncestors` read per tick.

There is no supported cross-plugin realtime subscription. BB changes therefore opportunistically re-check Redteam. After a snapshot discovers a queued/running Redteam scan, a server-side fallback probes that project/global snapshot every 3 seconds. Requests share the Redteam source's inflight guard and 1.5-second TTL and never overlap. Polling stops when observed scans are terminal or the view's watched read ends. While the global page is watched, the backend invalidates it every 20 seconds to discover brand-new Redteam work that has no BB event. BB thread changes remain event-driven.

The thread tree is read with one `experimental_listDescendants` call and one batched `threads.list({ threadIds })` row read; the per-thread timeline, model, output, event and queue reads run in parallel, six threads at a time.

## Bounds

Expand All @@ -52,11 +56,11 @@ The shared Timeline uses the existing responsive dialog/drawer primitive. Rows a

## Compatibility

- BB engine: `>=0.40`
- Runtime Plugin SDK floor: `>=0.4.21`
- Current development types: 0.4.34
- BB engine: `>=0.47`
- Runtime Plugin SDK floor: `>=0.6.73`
- Current development types: 0.6.132

Server modules use relative imports and avoid `@/` aliases so the BB 0.40 daemon loader can load source when artifact SDK versions differ. Newer lifecycle names are registered opportunistically, and missing newer thread fields degrade gracefully.
Server modules use relative imports and avoid `@/` aliases so the daemon loader can load source when artifact SDK versions differ. Newer lifecycle names are registered opportunistically, and missing newer thread fields degrade gracefully.

## Commands

Expand Down Expand Up @@ -119,7 +123,7 @@ All views share a Fleet cache and one physical inflight request. Idle cache TTL
is 20 seconds, matching the existing global discovery cycle. New route-tool
activity can refresh discovery at a bounded two-second TTL. Once active jobs are discovered,
one 2.5-second loop polls regardless of job count, stops at terminal state or
source failure, and expires unused watches after 90 seconds. No idle Fleet timer
source failure, and stops when no board or global page is watched. No idle Fleet timer
is created. Meaningful changes invalidate board/timeline generations and publish
Agent Board's normal signal. Invalidation during a source read discards its
result and retries sequentially; stale RUNNING cannot re-enter the cache.
Expand Down
138 changes: 39 additions & 99 deletions hooks/useBoard.ts
Original file line number Diff line number Diff line change
@@ -1,123 +1,56 @@
// bb-plugin-agent-board — the board data hook.
//
// One bounded fetch per invalidation signal: the backend publishes
// "agent-board-changed" and this hook refetches board_snapshot. No polling.
// A 1s local ticker drives live elapsed timers between snapshots.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-sdk/app";
import {
BOARD_CHANGED_CHANNEL,
boardChangedPayloadSchema,
type BoardSnapshot,
type rpcContract,
} from "@/contract/rpc";
// The board reads board_snapshot through BB's shared RPC query cache. The
// backend invalidates the board's tag when a watched thread changes, and the
// host refetches on invalidation and after reconnects. No polling. Holding
// the read is also what keeps the backend's watcher and pollers running for
// this root. A 1s local ticker drives live elapsed timers between snapshots.
import { useCallback, useEffect, useMemo, useState } from "react";
import { experimental_useRpcQuery, useRpc } from "@get-bb/plugin-sdk/app";
import { rpcContract, type BoardSnapshot } from "@/contract/rpc";

interface BoardState {
snapshot: BoardSnapshot | null;
/** Set after the first fetch for a threadId resolves or rejects. */
/** True until the first fetch for a threadId resolves or rejects. */
loading: boolean;
/** Fetch in flight after the first (manual refresh / signal refetch). */
/** Fetch in flight after the first (manual refresh / invalidation refetch). */
refreshing: boolean;
error: string | null;
/** Advances every second while any card is still live (running/waiting). */
tick: number;
refetch: (options?: { fresh?: boolean }) => void;
}

/**
* Parse a realtime payload defensively: only the `roots` array is trusted,
* and anything malformed means "refetch" (the safe default).
*/
export function isForThisBoard(payload: unknown, rootThreadId: string): boolean {
const parsed = boardChangedPayloadSchema.safeParse(payload);
if (!parsed.success) return true; // unreadable signal: assume relevant
const roots = parsed.data.roots;
if (roots.length === 0) return parsed.data.global !== true;
return roots.includes(rootThreadId);
}

export function useBoard(rootThreadId: string): BoardState {
const rpc = useRpc<typeof rpcContract>();
const [snapshot, setSnapshot] = useState<BoardSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const query = experimental_useRpcQuery({
contract: rpcContract,
method: "board_snapshot",
input: { threadId: rootThreadId },
});
const snapshot = query.data ?? null;
const [freshPending, setFreshPending] = useState(false);
const [tick, setTick] = useState(0);
const requestId = useRef(0);
const refetchQuery = query.refetch;

// `fresh` bypasses the backend's short-lived caches; the follow-up read
// then serves that fresh snapshot to every view of this board.
const refetch = useCallback(
(options?: { fresh?: boolean }) => {
const id = ++requestId.current;
if (snapshot === null) setLoading(true);
else setRefreshing(true);
rpc
.call("board_snapshot", {
threadId: rootThreadId,
fresh: options?.fresh === true,
})
.then(
(result) => {
if (id !== requestId.current) return; // a newer fetch superseded this one
setSnapshot(result);
setError(null);
},
(cause: unknown) => {
if (id !== requestId.current) return;
setError(cause instanceof Error ? cause.message : String(cause));
},
)
.finally(() => {
if (id !== requestId.current) return;
setLoading(false);
setRefreshing(false);
});
if (options?.fresh !== true) {
void refetchQuery();
return;
}
setFreshPending(true);
void rpc
.call("board_snapshot", { threadId: rootThreadId, fresh: true })
.catch(() => undefined)
.then(() => refetchQuery())
.finally(() => setFreshPending(false));
},
[rpc, rootThreadId, snapshot],
[rpc, rootThreadId, refetchQuery],
);

// Initial fetch when the board opens (and on thread change).
useEffect(() => {
requestId.current++;
setSnapshot(null);
setError(null);
setLoading(true);
rpc
.call("board_snapshot", { threadId: rootThreadId })
.then(
(result) => {
setSnapshot(result);
setError(null);
},
(cause: unknown) => {
setError(cause instanceof Error ? cause.message : String(cause));
},
)
.finally(() => {
setLoading(false);
});
}, [rpc, rootThreadId]);

// Backend invalidation → refetch (bounded: debounced server-side, cached
// server-side, race-guarded here).
useRealtime(BOARD_CHANGED_CHANNEL, (payload) => {
if (isForThisBoard(payload, rootThreadId)) refetch();
});

// After a realtime reconnect, reconcile: signals emitted while offline
// were missed, so refetch once (but not on the first connect — the mount
// effect already fetched).
const connectionState = useRealtimeConnectionState();
const everConnected = useRef(false);
const prevState = useRef(connectionState);
useEffect(() => {
const was = prevState.current;
prevState.current = connectionState;
if (connectionState === "connected" && was !== "connected" && everConnected.current) {
refetch();
}
if (connectionState === "connected") everConnected.current = true;
}, [connectionState, refetch]);

// Local timer: only while work is live, so idle boards cost nothing.
const hasLive = useMemo(
() =>
Expand All @@ -133,5 +66,12 @@ export function useBoard(rootThreadId: string): BoardState {
return () => clearInterval(timer);
}, [hasLive]);

return { snapshot, loading, refreshing, error, tick, refetch };
return {
snapshot,
loading: query.isLoading,
refreshing: freshPending || (query.isFetching && snapshot !== null),
error: query.error?.message ?? null,
tick,
refetch,
};
}
Loading