Skip to content

feat(bitwarden-beads-tools): add a plugin of tools that complement beads - #284

Open
SaintPatrck wants to merge 7 commits into
mainfrom
feat/bitwarden-beads-tools
Open

SaintPatrck wants to merge 7 commits into
mainfrom
feat/bitwarden-beads-tools

Conversation

@SaintPatrck

@SaintPatrck SaintPatrck commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

🎟️ Tracking

Self-identified improvement; no ticket.

📔 Objective

This adds bitwarden-beads-tools, starting with a beads pane that keeps the project's open, in-progress, and blocked beads beside the chat and in the status line, refreshing as bd commands run. Engineers tracking work in beads otherwise have no view of it inside Claude Code short of running bd by hand. It requires bd 1.3 or later, the first release with bd list --brief. The whole tool is one mods module, so hooks/register.tsx is the place to start reading.

📸 Screenshots

🤖 AI-assisted review

Reviewed locally with code-review-local and validate-ai-local; the one IMPORTANT finding (bd list --brief needs bd 1.3) is addressed on this branch.

The plugin passes --brief, which only bd 1.3 and later accept, so an older bd already read as no beads project and the bare-array fallback could never run. Headless runs have no one to see the pane, so they skip it and the 30-second poll, and the status line now reports a bd list it could not parse instead of reading it as none open.
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Claude Code validation

Result: Pass

Re-reviewed the bitwarden-beads-tools plugin against the pull request's merge base (8aa784a): the manifest and marketplace entry, hooks/hooks.json, the register.tsx mods module, its type declarations, and the plugin's tests. No secrets, no shell interpolation, and no permission or tool-grant widening — bd is invoked as a fixed argv array through $.process.run, and the Bash tool's e.command is only regex-tested, never executed. All four findings from the previous run remain resolved; the four findings below are all SUGGESTED and all posted inline.

Not covered: e.isInteractive on the session.start event (hooks/register.tsx:149) still could not be confirmed against the documentation — the mods reference documents when session.start fires and what a hook may return, but defers event field shapes to the per-build TypeScript declarations. Nothing is claimed about that field either way; claude plugin validate and claude plugin test are the authoritative check.

Findings
  • 🎨 SUGGESTED: Finding 1: A missing or broken bd is indistinguishable from "not a beads project" — both go silent, while a JSON parse failure does report an error (plugins/bitwarden-beads-tools/hooks/register.tsx:76, previously raised in this report and still open)
  • 🎨 SUGGESTED: Finding 2: Truncated bd stdout is reported to the user as "not JSON" rather than as truncation (plugins/bitwarden-beads-tools/hooks/register.tsx:81)
  • 🎨 SUGGESTED: Finding 3: A payload without an issues array renders as "No open beads." rather than as an unreadable response (plugins/bitwarden-beads-tools/hooks/register.tsx:85)
  • 🎨 SUGGESTED: Finding 4: Two of the four documented refresh triggers — the Bash tool.call hook and classic.CwdChanged — have no test (plugins/bitwarden-beads-tools/tests/beads.test.tsx:360)

Dropped after documentation check:

  • plugins/bitwarden-beads-tools/hooks/register.tsx:195 — classic.CwdChanged as a possibly misspelled event name. The hooks reference documents it: "CwdChanged | When the working directory changes, for example when Claude executes a cd command."
  • plugins/bitwarden-beads-tools/hooks/register.tsx:165 — fork as a possibly invalid SessionStart source in the matcher. The hooks reference documents it, and confirms it is what /branch reports: "| fork | A new session forked from an existing one: --fork-session with --resume or --continue, the /fork background copy, /branch, or a conversation you move to the background |" — so the code comment at line 161 naming /branch matches the value it filters on.

Dropped under the re-run rule: one candidate — that the 30-second poll is never disarmed when the session leaves the beads project (register.tsx:53) — was a finding against the canPoll gate added to fix the previous run's Finding 1. Re-reviewing a fix and raising the next round of work on it is what the re-run baseline exists to prevent, so it is recorded here rather than posted.

Previous findings, re-checked against the current diff:

  • Finding 1 (IMPORTANT, poll armed unconditionally) — still resolved. The interval is registered inside refresh behind next !== null && canPoll (register.tsx:51-54), and canPoll is set only by an interactive session.start. Covered by tests/beads.test.tsx:140 and :177.
  • Finding 2 (SUGGESTED, overlapping-refresh race) — still resolved. A generation counter makes an overtaken refresh skip both update and $.ui.status (register.tsx:31,38,41-43). Covered by tests/beads.test.tsx:247.
  • Finding 3 (SUGGESTED, snapshot reset after /clear) — still resolved. A classic.SessionStart hook filtered to ["clear", "resume", "fork"] re-reads the beads (register.tsx:163-171). Covered by tests/beads.test.tsx:311.
  • Finding 4 (SUGGESTED, README pane-width claim) — still resolved. README.md:40 states the 144-column threshold, the 110-column follow-up, that /beads-pane opens at any width, and that headless runs skip the pane and the poll.

@SaintPatrck SaintPatrck changed the title feat(bitwarden-beads-tools): add plugin with a beads pane and status line feat(bitwarden-beads-tools): add a plugin of tools that complement beads Oct 21, 2026
void $.ui.open({ id: PANE, title: "Beads" });
}
});
$.clock.every(POLL_MS, () => void refresh($));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: The 30-second poll is armed unconditionally, so the plugin keeps spawning bd every 30s in directories that are not beads projects — contradicting the README's "stays out of the way".

Details and fix

$.clock.every(POLL_MS, …) runs for the whole life of every interactive session, and it is never gated on whether the first refresh found anything. refresh → load always calls $.process.run(["bd", "list", …], { cwd, timeoutMs: 10_000 }), so when bd is missing, older than 1.3, or the directory has no beads database, every user who installs this plugin still pays a process spawn every 30 seconds — up to a 10-second hang each time if bd stalls — for the rest of the session.

plugins/bitwarden-beads-tools/README.md:29 promises the opposite: "In a directory that is not a beads project, bd list fails and the tool stays out of the way: no pane opens and the status line is left empty." The pane and status line do stay empty, but the subprocess does not stop.

Arm the interval only once a refresh has succeeded, and let classic.CwdChanged arm it when the session moves into a beads project:

    if (e.isInteractive) {
      void refresh($).then((found) => {
        if (found !== null) {
          void $.ui.open({ id: PANE, title: "Beads" });
          startPolling($);
        }
      });
    }

where startPolling registers $.clock.every(POLL_MS, …) at most once per session.

Reference: Mods reference — $.clock

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the poll now starts on the first refresh that finds a beads project, so a session outside one never runs bd on a timer. We arm it from refresh rather than only session.start and CwdChanged, which also covers a bd init mid-session.

@SaintPatrck SaintPatrck added the ai-review Request a Claude code review label Oct 22, 2026
…pane current

Outside a beads project the 30-second poll spawned bd for the whole session, a slow bd list could overwrite a newer result, and /clear, /resume, or /branch left the pane claiming there was no beads project until the next tick.
Comment on lines +47 to +48
const isQuiet =
next === null || (next.error === undefined && next.beads.length === 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isQuiet condition combines || and && on one line, requiring readers to track multiple logical sequences.

Show fix
Suggested change
const isQuiet =
next === null || (next.error === undefined && next.beads.length === 0);
const hasNoError = next !== null && next.error === undefined;
const hasNoBeads = next !== null && next.beads.length === 0;
const isQuiet = next === null || (hasNoError && hasNoBeads);
Details

✨ AI Reasoning
​The isQuiet expression combines an OR with a grouped AND on the same line. Readers must track how those conditions interact to determine whether the snapshot is quiet, making this more cognitively demanding than a single logical sequence.

Reply on this comment to give feedback or ignore the issue.
More info

…tools

# Conflicts:
#	.claude-plugin/marketplace.json
#	README.md
@SaintPatrck
SaintPatrck marked this pull request as ready for review October 23, 2026 21:03
@SaintPatrck
SaintPatrck requested a review from a team as a code owner October 23, 2026 21:03
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Since the last review on 2026-10-23, the branch has gained only two merges from main (f1f6820, 5cb53b9). Neither commit touches plugins/bitwarden-beads-tools/. The merges did change the shared .claude-plugin/marketplace.json and the README.md catalog table, and both still resolve cleanly: the marketplace JSON parses, plugin names are unique, and the bitwarden-beads-tools entry sits at 0.1.0 in the marketplace, in plugin.json, and in the README table. The earlier assessment of hooks/register.tsx, its tests, and the hook config still holds, and this run found nothing new.

…tools

# Conflicts:
#	.claude-plugin/marketplace.json
#	README.md
.run(argv, { cwd, timeoutMs: 10_000 })
.catch(() => null);

if (ran === null || ran.exitCode !== 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: A missing or broken bd is indistinguishable from "not a beads project" — both go silent, while a JSON parse failure does report an error.

Details and fix

Every failure mode collapses into the same null snapshot: bd absent from PATH (which rejects, so .catch(() => null) gives ran === null), a bd older than 1.3 rejecting --brief, a corrupt database, and a genuine non-project directory. All four suppress the pane and leave the status line empty, so a user whose bd is broken gets no signal at all. The asymmetry is visible in the code itself — the parse-failure path at line 88 does surface "bd list returned output that is not JSON".

This is partly deliberate: README.md:15 documents that "an older bd reads as a directory with no beads project," and silence outside a project is the stated design. The gap is narrower than the whole branch — a spawn failure is not the same as a clean non-zero exit, and only the former means the CLI is missing.

Separate the spawn failure from the non-zero exit, keeping null for the documented cases:

  if (ran === null) {
    return { beads: [], error: "bd not found on PATH" };
  }

  if (ran.exitCode !== 0) {
    return null;
  }

validate-ai

}

try {
const parsed: unknown = JSON.parse(ran.stdout || "{}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: Truncated bd stdout is reported to the user as "not JSON" rather than as truncation.

Details and fix

$.process.run caps stdout at 4 MiB and sets isStdoutTruncated when it cuts the output, but that field is never read here — the tests mock it on every process.run stub, so the shape is already known to the module. With -n 0 asking bd list for every row, a large enough beads project overflows the cap; the partial JSON then fails JSON.parse and the user sees beads: error reading bd list, which points at bd rather than at output size.

Low likelihood — --brief rows are small, so this needs on the order of tens of thousands of beads — but the failure is silent about its real cause when it happens.

Check for the cut before parsing:

  if (ran.isStdoutTruncated) {
    return { beads: [], error: "bd list output was too large to read" };
  }

Reference: Mods reference — Limits — "$.process.run stdout and stderr | 4 MiB each. isStdoutTruncated or isStderrTruncated is true when one was cut."

validate-ai

const rows = (parsed as { issues?: unknown } | null)?.issues;

return {
beads: Array.isArray(rows) ? rows.map(toBead).filter(isBead) : [],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: A payload without an issues array renders as "No open beads." rather than as an unreadable response.

Details and fix

Array.isArray(rows) ? rows.map(toBead).filter(isBead) : [] collapses "bd returned a shape we do not understand" into the same state as "this project has no open work". If a future bd renames the field or wraps the list, the pane and the status line confidently report an empty backlog while the project is full of open beads — a wrong answer is worse here than a visible error, and the module already has an error path for exactly this class of problem one line below.

Route the unexpected shape to that existing error path:

    if (!Array.isArray(rows)) {
      return { beads: [], error: "bd list returned an unexpected shape" };
    }

    return { beads: rows.map(toBead).filter(isBead) };

validate-ai


const ran = await $.command.run(RUN);
expect(JSON.stringify(ran)).toContain("No beads project here");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: Two of the four documented refresh triggers — the Bash tool.call hook and classic.CwdChanged — have no test.

Details and fix

The suite covers session start, /beads-pane, the poll, the overtake guard, and /clear, but nothing exercises the tool.call handler (register.tsx:185-193) or classic.CwdChanged (register.tsx:195-200). Both are listed in README.md:30 and CHANGELOG.md:16 as ways the pane stays current.

The Bash path turns on the regex /(^|[\s;&|(])bd\s/, which is the kind of expression that regresses silently — it already misses sh -c "bd close x", since the preceding " is not in the character class. A test would pin the behavior that is intended.

Add a case that fires a compound Bash call and asserts the refresh ran:

test("a bd command in a Bash call refreshes the beads", async ($, on) => {
  let runs = 0;
  on("session.cwd", () => ({ value: "/work/demo" }));
  on("process.run", () => {
    runs += 1;

    return {
      value: {
        exitCode: 0,
        stdout: JSON.stringify({ issues: BEADS }),
        stderr: "",
        isStdoutTruncated: false,
        isStderrTruncated: false,
      },
    };
  });
  on("ui.status", () => ({ value: undefined }));

  await $.tool.call({ tool: "Bash", command: "git commit -m x && bd close demo-1" });
  expect(runs).toBe(1);
});

validate-ai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant