@omercnet/paseo-beads 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,8 @@ A dependency-aware Beads work queue for every Paseo workspace. Paseo Beads turns
4
4
  a read-only delivery view that makes the ready frontier, work in progress, and blockers immediately
5
5
  visible.
6
6
 
7
- It adds a workspace-scoped **Beads** Explorer panel and an **Open Beads** Command Center item.
7
+ It adds a workspace-scoped **Beads** Explorer panel and an **Open Beads** Command Center item. On
8
+ Paseo 0.11 and newer it also adds a **Ready beads** sidebar row and a linkable bead screen.
8
9
 
9
10
  ## Demo
10
11
 
@@ -36,6 +37,10 @@ verified the rendered page contained no private organization names.
36
37
  layouts.
37
38
  - A virtualized issue list, descriptive accessibility labels, and focus restoration when navigating
38
39
  between the compact list and detail view.
40
+ - On Paseo 0.11 and newer, a **Ready beads** sidebar row with the number of ready beads across the
41
+ host's workspaces. Pressing it opens a popover (a bottom sheet in compact layouts) with the top 10
42
+ ready beads grouped by workspace. Pressing a bead opens its detail view as a screen whose URL
43
+ carries the workspace and bead IDs, so it can be linked and survives a reload.
39
44
 
40
45
  Within each lane, issues are ordered by priority, most recent update, then ID. In-progress and hooked
41
46
  issues enter **In progress** first; remaining blocked issues enter **Blocked**; ready issues enter
@@ -47,7 +52,10 @@ The panel calls workspace-scoped Paseo plugin RPC handlers. On the daemon host,
47
52
  the workspace directory through Paseo and executes the `bd` CLI with `--readonly` and `-C`:
48
53
 
49
54
  - The list handler runs `bd list --json --sort priority`, then runs one authoritative
50
- `bd list --ready --json --limit 0` query and intersects its IDs with the displayed issues.
55
+ `bd list --ready --json --limit 0` query and intersects its IDs with the displayed issues. It also
56
+ runs `bd where --json` and returns a digest of the database location as an opaque `databaseId`,
57
+ so workspaces that share one database, such as git worktrees, can be recognized. This is best
58
+ effort: when `bd` cannot say, the snapshot is the same without it.
51
59
  - Selecting an issue runs `bd show <issue-id> --json --include-dependents` plus the same authoritative
52
60
  readiness query.
53
61
 
@@ -58,9 +66,23 @@ The list and selected detail poll every 10 seconds. **Refresh** requests the lis
58
66
  failed background refresh keeps the previous data visible with an inline error; initial and detail
59
67
  failures provide a retry action.
60
68
 
69
+ The **Ready beads** row lists the host's workspaces through the Paseo SDK and calls the same list
70
+ handler for them, one workspace at a time, every 2 minutes while the app is in the foreground and
71
+ whenever its popover opens. A snapshot an open panel read in the last 10 seconds is reused instead
72
+ of rerunning `bd`. Workspaces that share a database, such as git worktrees, report the same
73
+ `databaseId`, so an issue counts once however many workspaces read it and is listed under the first
74
+ workspace that reported it. The first scan reads every workspace to learn which ones share a
75
+ database. Later scans read each database once and skip its other workspaces. A workspace is skipped
76
+ only while a successful read in the last 10 minutes vouches that it reads a database the scan has
77
+ already read, so a failed or older read puts it back in the scan. Workspaces without Beads count as
78
+ zero and are rechecked every 5 minutes. When `bd` is unavailable, or a workspace cannot be read,
79
+ the popover says so instead of reporting an empty host, and a failed refresh is shown next to the
80
+ last result. Once nothing shows the count, for example when the host disconnects, a scan that is
81
+ still running starts no further workspace reads.
82
+
61
83
  ## Limits
62
84
 
63
- - Paseo `^0.9.0 || ^0.10.0` with plugins enabled is required.
85
+ - Paseo `^0.9.0 || ^0.10.0 || ^0.11.0` with plugins enabled is required.
64
86
  - Beads `bd` 1.0 or newer must be available on the Paseo daemon's `PATH`.
65
87
  - A Beads project must be initialized in the workspace for issue data to appear.
66
88
  - CLI calls time out after 10 seconds and accept at most 8 MiB of output. Unexpected CLI details stay
@@ -74,6 +96,15 @@ failures provide a retry action.
74
96
  project, request failures, and missing issue details.
75
97
  - This plugin is read-only and workspace-scoped. It uses the `bd` CLI exclusively and does not
76
98
  create, edit, close, or assign issues.
99
+ - The **Ready beads** row and the bead screen need Paseo 0.11. On 0.9 and 0.10 they do not register,
100
+ and the panel and Command Center items work as before.
101
+ - The **Ready beads** count covers the first 200 workspaces the host lists and, like the panel, the
102
+ first 500 issues of each workspace. It refreshes about every two minutes while the app is in the
103
+ foreground, plus the time a scan takes, and when the popover opens, so it can be older after the
104
+ app was in the background. A workspace that moves to another database is noticed within 10
105
+ minutes.
106
+ - A `bd` that cannot report its database (`bd where --json`) leaves every workspace counted on its
107
+ own, so git worktrees of one project are counted separately.
77
108
 
78
109
  ## Install
79
110
 
@@ -96,7 +127,8 @@ Paseo shows the installed and proposed revisions and asks for approval before ap
96
127
  update. Review the source changes before approving them.
97
128
 
98
129
  Open a workspace, choose **New tab** in Explorer, then select **Beads**. You can also run **Open
99
- Beads** from the Command Center while viewing a workspace or one of its agents.
130
+ Beads** from the Command Center while viewing a workspace or one of its agents. On Paseo 0.11 and
131
+ newer, press **Ready beads** in the sidebar for ready work across every workspace on the host.
100
132
 
101
133
  ## Develop
102
134
 
@@ -1,4 +1,4 @@
1
- import type { BeadSummary } from "../shared/beads";
1
+ import { type BeadSummary, type BeadsSnapshot, getWorkspaceBead } from "../shared/beads";
2
2
 
3
3
  export const BEAD_LANES = ["ready", "in_progress", "blocked", "other"] as const;
4
4
 
@@ -153,3 +153,106 @@ export function buildBeadsView(
153
153
 
154
154
  return { lanes, counts };
155
155
  }
156
+
157
+ export const BEAD_SCREEN_ID = "bead";
158
+
159
+ /** Reads bead screen params (`{ workspace, bead }`) with the detail RPC's own input rules. */
160
+ export function parseBeadScreenParams(params: Readonly<Record<string, string>>) {
161
+ const parsed = getWorkspaceBead.input.safeParse({
162
+ workspaceId: params.workspace,
163
+ issueId: params.bead,
164
+ });
165
+ return parsed.success ? parsed.data : null;
166
+ }
167
+
168
+ /** Shared by the panel and the sidebar count, so either one reuses the other's snapshot. */
169
+ export function beadsSnapshotQueryKey(hostId: string, workspaceId: string) {
170
+ return ["paseo-beads", "snapshot", hostId, workspaceId] as const;
171
+ }
172
+
173
+ export interface ReadyBeadsWorkspace {
174
+ id: string;
175
+ projectDisplayName: string;
176
+ name: string;
177
+ }
178
+
179
+ export interface ReadyBeadsSummary {
180
+ /** Ready beads across every workspace that loaded. */
181
+ count: number;
182
+ /** The top ready beads, grouped under the workspace each one is listed for. */
183
+ groups: { workspace: ReadyBeadsWorkspace; beads: BeadSummary[] }[];
184
+ /** Workspaces whose snapshot failed to load. */
185
+ failed: number;
186
+ /** Workspaces on a host that cannot run `bd`. */
187
+ unavailable: number;
188
+ }
189
+
190
+ /**
191
+ * Workspaces that share a Beads database, such as git worktrees, report the same `databaseId`, so
192
+ * an issue counts once and is listed under the first workspace that reported it. Titles are
193
+ * display data, not identity. A snapshot that cannot name its database counts as having its own.
194
+ */
195
+ export function summarizeReadyBeads(
196
+ entries: readonly { workspace: ReadyBeadsWorkspace; snapshot: BeadsSnapshot | null }[],
197
+ limit: number,
198
+ ): ReadyBeadsSummary {
199
+ const seen = new Set<string>();
200
+ const ready: { workspace: ReadyBeadsWorkspace; bead: BeadSummary }[] = [];
201
+ let failed = 0;
202
+ let unavailable = 0;
203
+
204
+ for (const { workspace, snapshot } of entries) {
205
+ if (!snapshot) failed += 1;
206
+ else if (snapshot.state === "bd_unavailable") unavailable += 1;
207
+ const database = snapshot?.databaseId ?? workspace.id;
208
+ for (const bead of snapshot?.issues ?? []) {
209
+ const key = JSON.stringify([database, bead.id]);
210
+ if (beadLaneFor(bead) !== "ready" || seen.has(key)) continue;
211
+ seen.add(key);
212
+ ready.push({ workspace, bead });
213
+ }
214
+ }
215
+ ready.sort((left, right) => compareBeads(left.bead, right.bead));
216
+
217
+ const groups = new Map<string, ReadyBeadsSummary["groups"][number]>();
218
+ for (const { workspace, bead } of ready.slice(0, limit)) {
219
+ const group = groups.get(workspace.id) ?? { workspace, beads: [] };
220
+ group.beads.push(bead);
221
+ groups.set(workspace.id, group);
222
+ }
223
+
224
+ return { count: ready.length, groups: [...groups.values()], failed, unavailable };
225
+ }
226
+
227
+ export interface ReadyBeadsNotes {
228
+ /** Replaces the list when no ready bead was found; qualified when the result is incomplete. */
229
+ empty: string | null;
230
+ /** Caveats about the result, shown below it. */
231
+ notices: string[];
232
+ }
233
+
234
+ /** `refreshError` is the message of a failed refresh that left the previous result in place. */
235
+ export function describeReadyBeads(
236
+ { count, failed, unavailable }: ReadyBeadsSummary,
237
+ refreshError: string | null,
238
+ ): ReadyBeadsNotes {
239
+ const notices: string[] = [];
240
+ if (refreshError) notices.push(`Refresh failed, showing the last result. ${refreshError}`);
241
+ if (failed > 0) {
242
+ notices.push(`${failed === 1 ? "1 workspace" : `${failed} workspaces`} could not be read.`);
243
+ }
244
+ if (unavailable > 0 && count > 0) {
245
+ notices.push(
246
+ `The bd CLI was unavailable for ${unavailable === 1 ? "1 workspace" : `${unavailable} workspaces`}, so the count may be incomplete.`,
247
+ );
248
+ }
249
+
250
+ let empty: string | null = null;
251
+ if (count === 0) {
252
+ if (unavailable > 0) empty = "The bd CLI is not available on this Paseo host.";
253
+ else if (failed > 0 || refreshError) {
254
+ empty = "No ready beads found in the workspaces that could be checked.";
255
+ } else empty = "No ready beads in any workspace on this host.";
256
+ }
257
+ return { empty, notices };
258
+ }
@@ -1,4 +1,9 @@
1
- import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/plugin/client";
1
+ import {
2
+ type PluginScreenProps,
3
+ type PluginWorkspacePanelProps,
4
+ useRpc,
5
+ useWorkspace,
6
+ } from "@getpaseo/plugin/client";
2
7
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
8
  import { useQuery } from "@tanstack/react-query";
4
9
  import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react";
@@ -30,16 +35,18 @@ import {
30
35
  type BeadLane,
31
36
  type BeadSection,
32
37
  type BeadsFilter,
38
+ beadsSnapshotQueryKey,
33
39
  buildBeadSections,
34
40
  buildBeadsView,
35
41
  issueAccessibilityLabel,
42
+ parseBeadScreenParams,
36
43
  } from "./beads-view";
37
44
  import { focusWebElement } from "./web";
38
45
 
39
46
  const REFRESH_INTERVAL_MS = 10_000;
40
47
  const BACK_BUTTON_NATIVE_ID = "paseo-beads-back";
41
48
  const ISSUE_ROW_NATIVE_ID_PREFIX = "paseo-beads-issue-";
42
- const ID_FONT_FAMILY = Platform.select({ ios: "Menlo", default: "monospace" });
49
+ export const ID_FONT_FAMILY = Platform.select({ ios: "Menlo", default: "monospace" });
43
50
 
44
51
  const FILTERS: readonly { id: BeadsFilter; title: string }[] = [
45
52
  { id: "all", title: "All" },
@@ -47,7 +54,7 @@ const FILTERS: readonly { id: BeadsFilter; title: string }[] = [
47
54
  { id: "assigned", title: "Assigned" },
48
55
  ];
49
56
 
50
- function errorMessage(error: unknown): string {
57
+ export function errorMessage(error: unknown): string {
51
58
  return error instanceof Error ? error.message : "An unexpected error occurred.";
52
59
  }
53
60
 
@@ -69,7 +76,7 @@ function laneColor(lane: BeadLane, colors: PluginWorkspacePanelProps["theme"]["c
69
76
  return colors.foregroundMuted;
70
77
  }
71
78
 
72
- function priorityColor(
79
+ export function priorityColor(
73
80
  priority: number,
74
81
  colors: PluginWorkspacePanelProps["theme"]["colors"],
75
82
  ): string {
@@ -214,13 +221,13 @@ function DetailPane({
214
221
  styles,
215
222
  theme,
216
223
  }: {
217
- backButtonRef: RefObject<View | null>;
224
+ backButtonRef?: RefObject<View | null>;
218
225
  detail: BeadDetail | null | undefined;
219
226
  detailError: unknown;
220
227
  isDetailFetching: boolean;
221
228
  isDetailPending: boolean;
222
229
  missing: boolean;
223
- onBack(): void;
230
+ onBack?(): void;
224
231
  onRetry(): void;
225
232
  selectedId: string | null;
226
233
  showBack: boolean;
@@ -434,6 +441,45 @@ export function PaseoBeads(props: PluginWorkspacePanelProps) {
434
441
  return <WorkspaceBeads key={props.workspaceId} {...props} />;
435
442
  }
436
443
 
444
+ export function BeadScreen({ theme, layout, host, params }: PluginScreenProps) {
445
+ const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
446
+ const target = parseBeadScreenParams(params);
447
+ const loadDetail = useRpc(getWorkspaceBead);
448
+ const { data, error, isFetching, isPending, refetch } = useQuery({
449
+ queryKey: ["paseo-beads", "detail", host.id, target?.workspaceId, target?.issueId],
450
+ queryFn: () => loadDetail(target as NonNullable<typeof target>),
451
+ enabled: target !== null,
452
+ refetchInterval: REFRESH_INTERVAL_MS,
453
+ });
454
+
455
+ return (
456
+ <View style={styles.screen}>
457
+ {target ? (
458
+ <DetailPane
459
+ detail={data?.detail}
460
+ detailError={error}
461
+ isDetailFetching={isFetching}
462
+ isDetailPending={isPending}
463
+ missing={data?.detail === null}
464
+ onRetry={() => void refetch()}
465
+ selectedId={target.issueId}
466
+ showBack={false}
467
+ styles={styles}
468
+ theme={theme}
469
+ />
470
+ ) : (
471
+ <StateCard
472
+ body="This link needs a workspace ID and a bead ID."
473
+ icon="AlertTriangle"
474
+ styles={styles}
475
+ theme={theme}
476
+ title="Invalid bead link"
477
+ />
478
+ )}
479
+ </View>
480
+ );
481
+ }
482
+
437
483
  function WorkspaceBeads({ theme, layout, host, workspaceId }: PluginWorkspacePanelProps) {
438
484
  const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
439
485
  const workspaceTitle = useWorkspace(workspaceId, ({ name, title }) => title?.trim() || name);
@@ -449,7 +495,7 @@ function WorkspaceBeads({ theme, layout, host, workspaceId }: PluginWorkspacePan
449
495
  const listScrollOffsetRef = useRef(0);
450
496
 
451
497
  const snapshotKey = useMemo(
452
- () => ["paseo-beads", "snapshot", host.id, workspaceId] as const,
498
+ () => beadsSnapshotQueryKey(host.id, workspaceId),
453
499
  [host.id, workspaceId],
454
500
  );
455
501
  const {
@@ -0,0 +1,159 @@
1
+ import {
2
+ type PluginPopoverProps,
3
+ type PluginSidebarItemProps,
4
+ usePaseo,
5
+ useRpc,
6
+ } from "@getpaseo/plugin/client";
7
+ import { SidebarRow } from "@getpaseo/plugin/client/ui";
8
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import { useMemo } from "react";
10
+ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
11
+ import { getWorkspaceBeads } from "../shared/beads";
12
+ import { BEAD_SCREEN_ID, describeReadyBeads } from "./beads-view";
13
+ import { errorMessage, ID_FONT_FAMILY, priorityColor } from "./paseo-beads";
14
+ import { READY_LIST_LIMIT, scanReadyBeads } from "./ready-scan";
15
+
16
+ /** The scan reads every project's Beads, so it runs rarely in the background and on popover open. */
17
+ const READY_REFRESH_INTERVAL_MS = 120_000;
18
+
19
+ function useReadyBeads(hostId: string, refetchOnMount: true | "always") {
20
+ const paseo = usePaseo();
21
+ const queryClient = useQueryClient();
22
+ const loadSnapshot = useRpc(getWorkspaceBeads);
23
+ return useQuery({
24
+ queryKey: ["paseo-beads", "ready", hostId],
25
+ queryFn: ({ signal }) =>
26
+ scanReadyBeads({
27
+ hostId,
28
+ signal,
29
+ queryClient,
30
+ loadSnapshot,
31
+ listWorkspaces: async () => (await paseo.workspaces.list()).entries,
32
+ }),
33
+ staleTime: READY_REFRESH_INTERVAL_MS / 2,
34
+ refetchInterval: READY_REFRESH_INTERVAL_MS,
35
+ refetchOnMount,
36
+ refetchOnWindowFocus: false,
37
+ });
38
+ }
39
+
40
+ export function ReadyBeadsItem({
41
+ theme,
42
+ host,
43
+ currentScreen,
44
+ openPopover,
45
+ }: PluginSidebarItemProps) {
46
+ const { data } = useReadyBeads(host.id, true);
47
+ return (
48
+ <SidebarRow
49
+ icon="CircleDot"
50
+ active={currentScreen?.screenId === BEAD_SCREEN_ID}
51
+ onPress={() => openPopover(ReadyBeadsPopover)}
52
+ trailing={
53
+ data?.count ? (
54
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{data.count}</Text>
55
+ ) : null
56
+ }
57
+ />
58
+ );
59
+ }
60
+
61
+ function ReadyBeadsPopover({ theme, layout, host, openScreen }: PluginPopoverProps) {
62
+ const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
63
+ const { data, error } = useReadyBeads(host.id, "always");
64
+
65
+ if (!data) {
66
+ return (
67
+ <View style={styles.state}>
68
+ {error ? null : <ActivityIndicator color={theme.colors.accent} />}
69
+ <Text style={styles.note}>
70
+ {error ? `Could not load Beads. ${errorMessage(error)}` : "Loading ready beads…"}
71
+ </Text>
72
+ </View>
73
+ );
74
+ }
75
+
76
+ const { empty, notices } = describeReadyBeads(data, error ? errorMessage(error) : null);
77
+
78
+ return (
79
+ <View style={styles.list}>
80
+ {empty ? <Text style={styles.note}>{empty}</Text> : null}
81
+ {data.groups.map(({ workspace, beads }) => (
82
+ <View key={workspace.id} style={styles.group}>
83
+ <View style={styles.groupHeading}>
84
+ <Text accessibilityRole="header" style={styles.workspace} numberOfLines={1}>
85
+ {workspace.name}
86
+ </Text>
87
+ <Text style={styles.project} numberOfLines={1}>
88
+ {workspace.projectDisplayName}
89
+ </Text>
90
+ </View>
91
+ {beads.map((bead) => (
92
+ <Pressable
93
+ key={bead.id}
94
+ accessibilityRole="button"
95
+ accessibilityLabel={`Open ${bead.id}: ${bead.title}. Priority P${bead.priority}.`}
96
+ onPress={() =>
97
+ openScreen({
98
+ screenId: BEAD_SCREEN_ID,
99
+ params: { workspace: workspace.id, bead: bead.id },
100
+ })
101
+ }
102
+ style={({ pressed }) => [styles.row, pressed && styles.pressed]}
103
+ >
104
+ <Text
105
+ style={[styles.priority, { color: priorityColor(bead.priority, theme.colors) }]}
106
+ >
107
+ P{bead.priority}
108
+ </Text>
109
+ <Text style={styles.id}>{bead.id}</Text>
110
+ <Text style={styles.title} numberOfLines={1}>
111
+ {bead.title}
112
+ </Text>
113
+ </Pressable>
114
+ ))}
115
+ </View>
116
+ ))}
117
+ {data.count > READY_LIST_LIMIT ? (
118
+ <Text style={styles.note}>
119
+ Showing {READY_LIST_LIMIT} of {data.count} ready beads.
120
+ </Text>
121
+ ) : null}
122
+ {notices.map((notice) => (
123
+ <Text key={notice} style={styles.note}>
124
+ {notice}
125
+ </Text>
126
+ ))}
127
+ </View>
128
+ );
129
+ }
130
+
131
+ function createStyles(theme: PluginPopoverProps["theme"], compact: boolean) {
132
+ return StyleSheet.create({
133
+ list: { gap: 12 },
134
+ state: { alignItems: "center", gap: 8, paddingVertical: 12 },
135
+ group: { gap: 2 },
136
+ groupHeading: {
137
+ flexDirection: "row",
138
+ alignItems: "baseline",
139
+ gap: 6,
140
+ paddingHorizontal: 6,
141
+ paddingBottom: 2,
142
+ },
143
+ workspace: { flexShrink: 1, color: theme.colors.foreground, fontSize: 12, fontWeight: "700" },
144
+ project: { flexShrink: 1, color: theme.colors.foregroundMuted, fontSize: 11 },
145
+ row: {
146
+ minHeight: compact ? 44 : 32,
147
+ flexDirection: "row",
148
+ alignItems: "center",
149
+ gap: 8,
150
+ paddingHorizontal: 6,
151
+ borderRadius: 6,
152
+ },
153
+ pressed: { backgroundColor: theme.colors.surface2 },
154
+ priority: { fontSize: 11, fontWeight: "800" },
155
+ id: { color: theme.colors.foregroundMuted, fontSize: 11, fontFamily: ID_FONT_FAMILY },
156
+ title: { flex: 1, minWidth: 0, color: theme.colors.foreground, fontSize: 13 },
157
+ note: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17 },
158
+ });
159
+ }
@@ -0,0 +1,78 @@
1
+ import type { QueryClient, QueryKey } from "@tanstack/react-query";
2
+ import type { BeadsSnapshot } from "../shared/beads";
3
+ import {
4
+ beadsSnapshotQueryKey,
5
+ type ReadyBeadsSummary,
6
+ type ReadyBeadsWorkspace,
7
+ summarizeReadyBeads,
8
+ } from "./beads-view";
9
+
10
+ /** How many ready beads the popover lists; the sidebar count covers all of them. */
11
+ export const READY_LIST_LIMIT = 10;
12
+ /** The panel's polling interval: a snapshot an open panel just read is reused, not refetched. */
13
+ const SNAPSHOT_FRESH_MS = 10_000;
14
+ /** A workspace without Beads is rechecked this rarely: it seldom changes and each check runs bd. */
15
+ const NO_BEADS_FRESH_MS = 5 * 60_000;
16
+ /** How long a successful read vouches for the database a workspace reads. */
17
+ const DATABASE_MEMORY_MS = 10 * 60_000;
18
+
19
+ /**
20
+ * The database a workspace was last confirmed to read, or null when nothing vouches for it. The
21
+ * cache cannot decide this on its own: `gcTime` only drops queries nobody observes, and an open
22
+ * panel keeps a failing query's old data indefinitely. So the data must not be invalidated (a
23
+ * failed refresh keeps the old data but flags it) and must be recent.
24
+ */
25
+ function confirmedDatabase(queryClient: QueryClient, queryKey: QueryKey): string | null {
26
+ const state = queryClient.getQueryState<BeadsSnapshot>(queryKey);
27
+ if (!state || state.isInvalidated) return null;
28
+ if (Date.now() - state.dataUpdatedAt > DATABASE_MEMORY_MS) return null;
29
+ return state.data?.databaseId ?? null;
30
+ }
31
+
32
+ export interface ReadyBeadsScan {
33
+ hostId: string;
34
+ /** Stops the scan from starting more workspace reads once its query is cancelled. */
35
+ signal: AbortSignal;
36
+ queryClient: QueryClient;
37
+ listWorkspaces(): Promise<readonly ReadyBeadsWorkspace[]>;
38
+ loadSnapshot(input: { workspaceId: string }): Promise<BeadsSnapshot>;
39
+ }
40
+
41
+ /**
42
+ * Reads each workspace's Beads snapshot through the cache the panel polls, and summarizes the
43
+ * ready beads. A workspace is skipped only when it is confirmed to read a database that this scan
44
+ * has read, so the first scan reads every workspace and learns which ones share a database (git
45
+ * worktrees do), and later scans read each database once. A workspace that cannot be read counts
46
+ * as failed.
47
+ */
48
+ export async function scanReadyBeads({
49
+ hostId,
50
+ signal,
51
+ queryClient,
52
+ listWorkspaces,
53
+ loadSnapshot,
54
+ }: ReadyBeadsScan): Promise<ReadyBeadsSummary> {
55
+ const readDatabases = new Set<string>();
56
+ const loaded: { workspace: ReadyBeadsWorkspace; snapshot: BeadsSnapshot | null }[] = [];
57
+ // ponytail: first page of the workspace list (up to 200), read one at a time so bd load stays
58
+ // flat. Add a small pool if the count lags on hosts with many Beads projects.
59
+ for (const workspace of await listWorkspaces()) {
60
+ if (signal.aborted) throw new Error("The ready beads scan was cancelled.");
61
+ const queryKey = beadsSnapshotQueryKey(hostId, workspace.id);
62
+ const knownDatabase = confirmedDatabase(queryClient, queryKey);
63
+ if (knownDatabase && readDatabases.has(knownDatabase)) continue;
64
+ // fetchQuery, not query(): the host app's query-core (5.90) has no query().
65
+ const snapshot = await queryClient
66
+ .fetchQuery({
67
+ queryKey,
68
+ queryFn: () => loadSnapshot({ workspaceId: workspace.id }),
69
+ staleTime: (query) =>
70
+ query.state.data?.state === "not_initialized" ? NO_BEADS_FRESH_MS : SNAPSHOT_FRESH_MS,
71
+ gcTime: DATABASE_MEMORY_MS, // storage cleanup only; confirmedDatabase enforces the age
72
+ })
73
+ .catch(() => null);
74
+ if (snapshot?.databaseId) readDatabases.add(snapshot.databaseId);
75
+ loaded.push({ workspace, snapshot });
76
+ }
77
+ return summarizeReadyBeads(loaded, READY_LIST_LIMIT);
78
+ }
package/index.client.tsx CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
- import { PaseoBeads } from "./client/paseo-beads";
2
+ import { BEAD_SCREEN_ID, parseBeadScreenParams } from "./client/beads-view";
3
+ import { BeadScreen, PaseoBeads } from "./client/paseo-beads";
4
+ import { ReadyBeadsItem } from "./client/ready-beads";
3
5
 
4
6
  export default function contribute(client: PluginClientContext) {
5
7
  const removePanel = client.addWorkspacePanel({
@@ -30,8 +32,25 @@ export default function contribute(client: PluginClientContext) {
30
32
  openPanel("beads");
31
33
  },
32
34
  });
35
+ // Paseo 0.11 added screens and sidebar header items. 0.9 and 0.10 keep the panel and commands.
36
+ const removeReadyBeads =
37
+ typeof client.addScreen === "function" && typeof client.addSidebarHeaderItem === "function"
38
+ ? [
39
+ client.addScreen({
40
+ id: BEAD_SCREEN_ID,
41
+ title: (params) => parseBeadScreenParams(params)?.issueId ?? "Bead",
42
+ Component: BeadScreen,
43
+ }),
44
+ client.addSidebarHeaderItem({
45
+ id: "ready-beads",
46
+ title: "Ready beads",
47
+ Component: ReadyBeadsItem,
48
+ }),
49
+ ]
50
+ : [];
33
51
 
34
52
  return () => {
53
+ for (const remove of removeReadyBeads) remove();
35
54
  removeAgentCommand();
36
55
  removeWorkspaceCommand();
37
56
  removePanel();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-beads",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "A dependency-aware Beads work queue for each Paseo workspace.",
6
6
  "license": "MIT",
@@ -49,10 +49,10 @@
49
49
  },
50
50
  "devDependencies": {
51
51
  "@biomejs/biome": "^2.5.10",
52
- "@getpaseo/cli": "0.10.1",
53
- "@getpaseo/client": "0.10.1",
54
- "@getpaseo/plugin": "0.10.1",
55
- "@getpaseo/protocol": "0.10.1",
52
+ "@getpaseo/cli": "0.11.0-beta.3",
53
+ "@getpaseo/client": "0.11.0-beta.3",
54
+ "@getpaseo/plugin": "0.11.0-beta.3",
55
+ "@getpaseo/protocol": "0.11.0-beta.3",
56
56
  "@tanstack/react-query": "^5.102.3",
57
57
  "@types/node": "^24.10.1",
58
58
  "@types/react": "^19.2.18",
package/paseo-plugin.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "id": "paseo-beads",
3
3
  "description": "A dependency-aware Beads work queue for each Paseo workspace.",
4
- "requirements": { "paseo": "^0.9.0 || ^0.10.0" }
4
+ "requirements": { "paseo": "^0.9.0 || ^0.10.0 || ^0.11.0" }
5
5
  }
package/server/beads.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { promisify } from "node:util";
3
4
  import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
4
5
  import type { PluginHandlerContext } from "@getpaseo/plugin/server";
@@ -18,11 +19,16 @@ const execFileAsync = promisify(execFile);
18
19
  const COMMAND_TIMEOUT_MS = 10_000;
19
20
  const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
20
21
  const MAX_DIAGNOSTIC_CHARS = 512;
22
+ const MAX_PATH_CHARS = 4_096;
21
23
 
22
24
  const rawIdentifierSchema = z.string().max(BEADS_LIMITS.identifier);
23
25
  const rawTitleSchema = z.string().max(BEADS_LIMITS.title);
24
26
  const rawStatusOrTypeSchema = z.string().max(BEADS_LIMITS.statusOrType);
25
27
  const rawTimestampSchema = z.string().max(BEADS_LIMITS.timestamp).datetime();
28
+ const RawDatabaseLocationSchema = z.object({
29
+ database_path: z.string().min(1).max(MAX_PATH_CHARS).optional(),
30
+ path: z.string().min(1).max(MAX_PATH_CHARS).optional(),
31
+ });
26
32
 
27
33
  const RawBeadSummarySchema = z.object({
28
34
  id: rawIdentifierSchema,
@@ -141,6 +147,24 @@ async function loadReadyIds(
141
147
  return new Set(RawReadyIssueListSchema.parse(result).map(({ id }) => id));
142
148
  }
143
149
 
150
+ /**
151
+ * Names the database `bd` resolves for a directory, so workspaces that share one (git worktrees,
152
+ * redirects) can be counted once. The ID is a digest, so no path leaves the daemon. Best effort:
153
+ * an older `bd` or a failing `bd where` leaves it null, and the list and ready queries still decide
154
+ * the snapshot's state.
155
+ */
156
+ async function loadDatabaseId(directory: string, runner: BdCommandRunner): Promise<string | null> {
157
+ try {
158
+ const location = RawDatabaseLocationSchema.parse(
159
+ await runBd(directory, ["where", "--json"], runner),
160
+ );
161
+ const database = location.database_path ?? location.path;
162
+ return database ? createHash("sha256").update(database).digest("hex").slice(0, 16) : null;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
144
168
  function errorText(error: unknown): string {
145
169
  if (!(error instanceof Error)) {
146
170
  return typeof error === "string"
@@ -249,6 +273,7 @@ function unavailableSnapshot(state: "not_initialized" | "bd_unavailable") {
249
273
  issues: [],
250
274
  truncated: false,
251
275
  refreshedAt: new Date().toISOString(),
276
+ databaseId: null,
252
277
  message:
253
278
  state === "bd_unavailable"
254
279
  ? "The bd CLI is not available on this Paseo host."
@@ -264,13 +289,14 @@ export async function handleGetWorkspaceBeads(
264
289
  const directory = await resolveWorkspaceDirectory(workspaceId, context);
265
290
 
266
291
  try {
267
- const [listJson, readyIds] = await Promise.all([
292
+ const [listJson, readyIds, databaseId] = await Promise.all([
268
293
  runBd(
269
294
  directory,
270
295
  ["list", "--json", "--sort", "priority", "--limit", String(BEADS_LIMITS.rawIssueList)],
271
296
  runner,
272
297
  ),
273
298
  loadReadyIds(directory, runner),
299
+ loadDatabaseId(directory, runner),
274
300
  ]);
275
301
  const listed = RawIssueListSchema.parse(listJson);
276
302
  const truncated = listed.length > BEADS_LIMITS.issueList;
@@ -282,6 +308,7 @@ export async function handleGetWorkspaceBeads(
282
308
  truncated,
283
309
  refreshedAt: new Date().toISOString(),
284
310
  message: null,
311
+ databaseId,
285
312
  });
286
313
  } catch (error) {
287
314
  if (isBdUnavailable(error)) return unavailableSnapshot("bd_unavailable");
package/shared/beads.ts CHANGED
@@ -62,6 +62,11 @@ export const BeadsSnapshotSchema = z.object({
62
62
  truncated: z.boolean(),
63
63
  refreshedAt: timestampSchema,
64
64
  message: z.string().max(BEADS_LIMITS.publicMessage).nullable(),
65
+ /**
66
+ * Opaque ID of the Beads database behind the snapshot. Workspaces that share one database, such
67
+ * as git worktrees, report the same ID. Null or absent when `bd` could not say.
68
+ */
69
+ databaseId: identifierSchema.nullish(),
65
70
  });
66
71
 
67
72
  export type BeadSummary = z.infer<typeof BeadSummarySchema>;