diff --git a/README.md b/README.md
index 31d205d..923b90a 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,62 @@
# Be concise for Paseo
-A native companion to [be-concise](https://github.com/yannelli/be-concise) for Claude Code and Codex. Open **Be concise** in Paseo's sidebar, workspace panels, or Command Center. The composer badge shows enforcement status and decision counts. It opens a quick preview with a workspace enforcement toggle.
+A native companion to [be-concise](https://github.com/yannelli/be-concise) for Claude Code and Codex. Open **Be concise** in Paseo's sidebar, workspace panels, or Command Center. The composer pill shows the rejected and flagged total when enforcement is on and **Off** when disabled. Open it for decision details and the workspace enforcement toggle.
- Live hook decisions, searchable by tool, file, session, project, and reason. Pause the feed or open an event’s request and response.
-- A composer badge with a monochrome icon, enforcement status, and decision counts; a compact popup with workspace stats and an enforcement switch. Turning enforcement off sets the workspace's `softFail` override; flagged actions are allowed while hooks and test-output filtering remain active. Turn it on to restore enforcement. Agent environment overrides apply separately.
+- A compact composer pill with the Be concise logo and one number. The number combines rejected and flagged decisions; the popup shows each decision count and a 30-minute chart. Turning enforcement off displays **Off** and sets the workspace's `softFail` override; flagged actions are allowed while hooks and test-output filtering remain active. Turn it on to restore enforcement. Agent environment overrides apply separately.
- Call counts, interventions, sessions, average hook duration, and a 30-minute activity chart.
- User and project configuration with threshold controls, check switches, writing presets, an advanced JSON editor, and test filter settings.
- An isolated playground for file writes, Codex patches, shell commands, and final replies. Previews inspect hook responses without executing the pasted command or writing the target file.
+## Screenshots
+
+All images are 1920×1080. They come from the Paseo 0.11.0-beta.3 web app with this plugin installed, using sample activity on a separate test daemon. The first five frame real captures with a headline. See [capture details](docs/composer-pill.md#screenshots).
+
+### Composer pill
+
+
+
+### Live activity
+
+
+
+### Configuration in light mode
+
+
+
+### Hook preview
+
+
+
+### Narrow screens
+
+
+
+### Enforcement off
+
+
+
+### Paused and filtered activity
+
+
+
+
+Connection and empty states
+
+### Connecting
+
+
+
+### Unavailable
+
+
+
+### No activity
+
+
+
+
+
## Install
Requires Paseo 0.9.0 or newer (tested against the 0.10.2 plugin SDK) and be-concise 0.7.0 or newer on the daemon machine. The adapter uses the configuration, monitoring, and preview APIs shipped with be-concise 0.7.0. Paseo supplies all runtime dependencies.
@@ -52,6 +101,8 @@ Tests use Node.js 24 and temporary home/project directories. They cover activity
Paseo API reference: [v0.9 plugins](https://paseo.sh/docs/plugins/v0.9/reference).
+See the [documentation index](docs/INDEX.md) for the current composer button contract and screenshot capture details. Consult it when changing pill labels or updating screenshots.
+
## Releases
GitHub Actions runs the checks and publishes a GitHub release on qualifying pushes to `main`. The first release is `v0.1.0`. The workflow updates `package.json` and `package-lock.json`, pushes an annotated tag, and publishes release notes. It uses the repository's `GITHUB_TOKEN`; no extra secrets or npm publication are required.
diff --git a/client/composer.tsx b/client/composer.tsx
index 8ac8521..2704809 100644
--- a/client/composer.tsx
+++ b/client/composer.tsx
@@ -1,3 +1,4 @@
+import type { PluginTheme } from "@getpaseo/plugin";
import {
type PluginButtonContentProps,
type PluginButtonIconProps,
@@ -10,33 +11,51 @@ import {
import { Icon } from "@getpaseo/plugin/client/react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
-import { Pressable, Text, View } from "react-native";
+import { Pressable, Text, View, type ViewStyle } from "react-native";
import { readConfiguration, snapshot, writeConfiguration } from "../shared/contracts";
import { enforcementUpdate } from "../shared/enforcement";
import { BrandIcon } from "./brand";
-import { decisionColor, Label, ToggleIndicator } from "./ui";
+import { Label, ToggleIndicator } from "./ui";
const PILL_ID = "concise";
-const PILL_TITLE = "Be concise: quick preview and enforcement";
const POLL_MS = 3000;
const decisionGroups = [
- { label: "allowed", names: ["allow"], icon: "Check", decision: "allow" },
- { label: "rejected", names: ["deny", "block"], icon: "X", decision: "deny" },
- { label: "flagged", names: ["ask", "flag"], icon: "TriangleAlert", decision: "flag" },
- { label: "rewritten", names: ["rewrite"], icon: "Pencil", decision: "rewrite" },
- { label: "bypassed", names: ["bypass"], icon: "SkipForward", decision: "bypass" },
- { label: "errors", names: ["error"], icon: "CircleAlert", decision: "error" },
+ { label: "allowed", names: ["allow"], decision: "allow" },
+ { label: "rejected", names: ["deny", "block"], decision: "deny" },
+ { label: "flagged", names: ["ask", "flag"], decision: "flag" },
+ { label: "rewritten", names: ["rewrite"], decision: "rewrite" },
+ { label: "bypassed", names: ["bypass"], decision: "bypass" },
+ { label: "errors", names: ["error"], decision: "error" },
];
+const CHART_HEIGHT = 40;
+
+function attentionColor(theme: PluginTheme, decision: string) {
+ if (["deny", "block", "error"].includes(decision)) return theme.colors.statusDanger;
+ if (["ask", "flag"].includes(decision)) return theme.colors.statusWarning;
+ return null;
+}
+
+const stackOrder = (decision: string) => ["deny", "block", "error"].includes(decision) ? 2 : ["ask", "flag"].includes(decision) ? 1 : 0;
+
+function Swatch({ theme, decision, style }: { theme: PluginTheme; decision: string; style: ViewStyle }) {
+ const color = attentionColor(theme, decision);
+ return ;
+}
+
+function decisionCounts(decisions: { name: string; count: number }[]) {
+ return decisionGroups.map((group) => ({ ...group, count: decisions.reduce((sum, item) => sum + (group.names.includes(item.name) ? item.count : 0), 0) }))
+ .filter((item, index) => index < 2 || item.count > 0);
+}
-/** Renders the brand mark tinted to the host's assigned pill color; the label carries live status. */
function ConciseIcon({ theme, size, color }: PluginButtonIconProps) {
return ;
}
-/** Short live status for the pill label, e.g. "Enabled · 3 flagged". Mirrors the poll loop's own copy in attach(). */
-function statusLabel(connected: boolean, bypassed: boolean, flagged: number): string {
- const base = !connected ? "Connecting" : bypassed ? "Bypassed" : "Enabled";
- return flagged > 0 ? `${base} · ${flagged} flagged` : base;
+function pillStatus(state: "Connecting" | "Enabled" | "Bypassed" | "Unavailable", decisions: { name: string; count: number }[] = []) {
+ const attention = decisionCounts(decisions).filter((item) => item.decision === "deny" || item.decision === "flag").reduce((sum, item) => sum + item.count, 0);
+ const labels = { Connecting: "…", Enabled: String(attention), Bypassed: "Off", Unavailable: "!" };
+ const summaries = { Connecting: "Connecting", Enabled: `${attention} rejected or flagged`, Bypassed: "Enforcement off", Unavailable: "Unavailable" };
+ return { label: labels[state], icon: ConciseIcon, title: `Be concise · ${summaries[state]}` };
}
function ConciseContent(props: PluginButtonContentProps & { onOpenDetails: () => void }) {
@@ -57,7 +76,7 @@ function ConciseAgentContent({ theme, workspaceId, agentId, onOpenDetails }: Ext
const bypassed = config.data?.effective.softFail === true;
const failed = activity.isError || config.isError;
const ready = activity.data?.connected && Boolean(config.data) && !failed;
- const counts = decisionGroups.map((group) => ({ ...group, count: (activity.data?.stats.decisions ?? []).reduce((sum, item) => sum + (group.names.includes(item.name) ? item.count : 0), 0) }));
+ const counts = decisionCounts(activity.data?.stats.decisions ?? []);
const minutes = (activity.data?.stats.minutes ?? []).map((minute) => {
const start = Date.parse(minute.timestamp);
const decisions = new Map();
@@ -65,7 +84,7 @@ function ConciseAgentContent({ theme, workspaceId, agentId, onOpenDetails }: Ext
const timestamp = Date.parse(event.timestamp);
if (timestamp >= start && timestamp < start + 60_000) decisions.set(event.decision, (decisions.get(event.decision) ?? 0) + 1);
}
- return { ...minute, decisions: [...decisions].sort(([a], [b]) => a.localeCompare(b)) };
+ return { ...minute, decisions: [...decisions].sort(([a], [b]) => stackOrder(a) - stackOrder(b) || a.localeCompare(b)) };
});
const maximum = Math.max(1, ...minutes.map((minute) => minute.count));
const mutation = useMutation({ mutationFn: async (enabled: boolean) => {
@@ -77,6 +96,8 @@ function ConciseAgentContent({ theme, workspaceId, agentId, onOpenDetails }: Ext
setNotice(data.effective.softFail === !enabled ? "" : "Saved. A daemon environment override controls enforcement.");
} });
const disabled = !ready || mutation.isPending;
+ const problem = mutation.error?.message ?? activity.data?.message ?? activity.error?.message ?? config.error?.message;
+ const status = !ready ? problem ? "be-concise is unavailable." : "Connecting…" : bypassed ? "Flagged actions allowed. Filtering stays on." : "Enforcing rules in this workspace.";
return
{ setNotice(""); mutation.mutate(bypassed); }}
@@ -84,23 +105,30 @@ function ConciseAgentContent({ theme, workspaceId, agentId, onOpenDetails }: Ext
Be concise
-
+
-
+ {ready && }
- {ready &&
- {counts.filter((item, index) => index < 2 || item.count > 0).map((item) =>
-
-
+ {ready &&
+ {counts.map((item) =>
+ {item.count}
+
+
+
+
)}
}
- {ready &&
-
+ {ready &&
+
{minutes.map((minute) => `, ${count} ${decision}`).join("")}`}
- style={{ flex: 1, height: 46, flexDirection: "column-reverse" }}>
- {minute.decisions.map(([decision, count]) => )}
+ style={{ flex: 1, height: CHART_HEIGHT, flexDirection: "column-reverse" }}>
+ {minute.count === 0
+ ?
+ :
+ {minute.decisions.map(([decision, count]) => )}
+ }
)}
@@ -109,8 +137,7 @@ function ConciseAgentContent({ theme, workspaceId, agentId, onOpenDetails }: Ext
{!minutes.some((minute) => minute.count > 0) && }
}
- {(activity.error || config.error || mutation.error) && }
- {activity.data?.message && }
+ {problem && }
{!!notice && }
; label: string; timer: ReturnType; pending: boolean };
+ type Poll = { registrations: Set; status: ReturnType; timer: ReturnType; pending: boolean };
const polls = new Map();
const pills = new Map();
let disposed = false;
@@ -152,7 +179,7 @@ export function contributeComposer(client: PluginClientContext) {
const registration = client.addComposerPill({
id: PILL_ID, workspaceId, agentId,
button: {
- title: PILL_TITLE, label: polls.get(workspaceId)?.label ?? "Connecting", icon: ConciseIcon,
+ ...polls.get(workspaceId)?.status ?? pillStatus("Connecting"),
behavior: { kind: "popover", Content: (props) => { props.close(); client.openPanel("workspace", { workspaceId }); }} /> },
},
});
@@ -161,26 +188,25 @@ export function contributeComposer(client: PluginClientContext) {
if (existing) { existing.registrations.add(registration); return; }
const workspace = client.paseo.workspaces.ref(workspaceId);
const timer = setInterval(() => void refresh(), POLL_MS);
- const poll: Poll = { registrations: new Set([registration]), label: "Connecting", timer, pending: false };
+ const poll: Poll = { registrations: new Set([registration]), status: pillStatus("Connecting"), timer, pending: false };
polls.set(workspaceId, poll);
const live = () => !disposed && polls.get(workspaceId) === poll;
- function update(label: string) {
+ function update(status: ReturnType) {
if (!live()) return;
- poll.label = label;
- for (const item of poll.registrations) item.update({ label });
+ poll.status = status;
+ for (const item of poll.registrations) item.update(status);
}
async function refresh() {
if (!live() || poll.pending) return;
poll.pending = true;
try {
const cwd = workspace.directory ?? (await workspace.refresh())?.workspaceDirectory;
- if (!cwd) { update("Connecting"); return; }
+ if (!cwd) { update(pillStatus("Connecting")); return; }
const [snap, config] = await Promise.all([client.rpc(snapshot, { cwd }), client.rpc(readConfiguration, { cwd })]);
const bypassed = config.effective.softFail === true;
- const flagged = snap.stats.decisions.reduce((sum, item) => sum + (["ask", "flag"].includes(item.name) ? item.count : 0), 0);
- update(statusLabel(snap.connected, bypassed, flagged));
+ update(pillStatus(!snap.connected ? "Connecting" : bypassed ? "Bypassed" : "Enabled", snap.stats.decisions));
} catch {
- update("Unavailable");
+ update(pillStatus("Unavailable"));
} finally {
poll.pending = false;
}
diff --git a/docs/INDEX.md b/docs/INDEX.md
new file mode 100644
index 0000000..65bc31d
--- /dev/null
+++ b/docs/INDEX.md
@@ -0,0 +1,5 @@
+# Documentation
+
+Created: 2026-10-04. Last updated: 2026-10-04.
+
+- [Composer pill and screenshots](composer-pill.md): compact status labels, the Paseo button contract, and screenshot capture details. Read this before changing labels or refreshing images.
diff --git a/docs/composer-pill.md b/docs/composer-pill.md
new file mode 100644
index 0000000..01ee378
--- /dev/null
+++ b/docs/composer-pill.md
@@ -0,0 +1,30 @@
+# Composer pill and screenshots
+
+Created: 2026-10-04. Last updated: 2026-10-04.
+
+## Button contract
+
+Paseo composer pills display an icon and a non-empty label. Omitting `label` displays `title`. The title also supplies the accessible label, tooltip, and sheet title. See the [Paseo composer pill reference](https://paseo.sh/docs/plugins/reference#composer-pills) and [button descriptor](https://paseo.sh/docs/plugins/reference#button-descriptor), checked on 2026-10-04.
+
+Every pill state uses the monochrome Be concise logo. The enabled label is the combined rejected and flagged count. The popup lists each decision count, and its 30-minute chart colors only rejected and flagged decisions. The title is a short summary, such as `Be concise · 5 rejected or flagged`, so the mobile sheet title fits. Agents in the same workspace share the polled label, icon, and title.
+
+| State | Label |
+| --- | --- |
+| Enforcing rules | One count, for example `11` |
+| Workspace enforcement bypassed | `Off` |
+| Connecting | `…` |
+| Read failed | `!` |
+
+The pill counts `deny`, `block`, `ask`, and `flag`. Allowed, rewritten, bypassed, and error counts stay in the popup.
+
+## Screenshots
+
+The [README gallery](../README.md#screenshots) uses scenario headings and PNG links, following the [Shared Browser example](https://github.com/omercnet/paseo-plugins/tree/d7b3e654f364b5be72edf6fd1d914a3750b53082/paseo-shared-browser#screenshots). The promo banners come first. The connection and empty states come last.
+
+Every image in [images/](images/) is 1920×1080 (16:9). Captures were taken on 2026-10-04 from the Paseo 0.11.0-beta.3 web app, served by a separate test daemon with this plugin installed from the repository and be-concise 0.8.2. Sample hook records, a sample Git project, and an imported sample Claude Code session supplied the data. No prompt was sent to an agent.
+
+- **Banners** (`composer-on`, `activity-desktop`, `configuration-light`, `playground`, `compact`): a 960×540 HTML layout rendered at 2× around crops of 1440×900 captures at 2× pixel density. The narrow-screen banner shows two 390×844 captures.
+- **Plain captures** (`composer-off`, `activity-filtered`, `composer-connecting`, `composer-unavailable`, `activity-empty`): 1280×720 viewports at 1.5× pixel density.
+- **States**: Off was set through the project's `softFail` setting. Connecting was captured while the plugin process was paused. Unavailable was captured with be-concise removed from the test home. Empty was captured with no hook records.
+
+The test daemon reported the host name `devbox`. Native mobile apps and the desktop app were not checked.
diff --git a/docs/images/activity-desktop.png b/docs/images/activity-desktop.png
new file mode 100644
index 0000000..05176a1
Binary files /dev/null and b/docs/images/activity-desktop.png differ
diff --git a/docs/images/activity-empty.png b/docs/images/activity-empty.png
new file mode 100644
index 0000000..0c6f966
Binary files /dev/null and b/docs/images/activity-empty.png differ
diff --git a/docs/images/activity-filtered.png b/docs/images/activity-filtered.png
new file mode 100644
index 0000000..930e09a
Binary files /dev/null and b/docs/images/activity-filtered.png differ
diff --git a/docs/images/compact.png b/docs/images/compact.png
new file mode 100644
index 0000000..a5e965a
Binary files /dev/null and b/docs/images/compact.png differ
diff --git a/docs/images/composer-connecting.png b/docs/images/composer-connecting.png
new file mode 100644
index 0000000..844b4ee
Binary files /dev/null and b/docs/images/composer-connecting.png differ
diff --git a/docs/images/composer-off.png b/docs/images/composer-off.png
new file mode 100644
index 0000000..371d9f5
Binary files /dev/null and b/docs/images/composer-off.png differ
diff --git a/docs/images/composer-on.png b/docs/images/composer-on.png
new file mode 100644
index 0000000..5fdd1ef
Binary files /dev/null and b/docs/images/composer-on.png differ
diff --git a/docs/images/composer-unavailable.png b/docs/images/composer-unavailable.png
new file mode 100644
index 0000000..9b2816f
Binary files /dev/null and b/docs/images/composer-unavailable.png differ
diff --git a/docs/images/configuration-light.png b/docs/images/configuration-light.png
new file mode 100644
index 0000000..046f5df
Binary files /dev/null and b/docs/images/configuration-light.png differ
diff --git a/docs/images/playground.png b/docs/images/playground.png
new file mode 100644
index 0000000..13234e4
Binary files /dev/null and b/docs/images/playground.png differ
diff --git a/test/composer.test.mts b/test/composer.test.mts
new file mode 100644
index 0000000..7c69066
--- /dev/null
+++ b/test/composer.test.mts
@@ -0,0 +1,178 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import { runInNewContext } from "node:vm";
+import type { PluginButton, PluginClientContext, PluginComposerPillContribution } from "@getpaseo/plugin/client";
+import ts from "typescript";
+import { readConfiguration, snapshot } from "../shared/contracts.ts";
+
+const source = await readFile(new URL("../client/composer.tsx", import.meta.url), "utf8");
+const compiled = ts.transpileModule(source, {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2020 },
+}).outputText;
+
+type Agent = { id: string; workspaceId: string; provider: string; status: string };
+type Subscription = {
+ snapshot(value: { entries: { agent: Agent }[] }): void;
+ update(value: { type: "agent_update"; payload: { kind: "upsert"; agent: Agent } | { kind: "remove"; agentId: string } }): void;
+};
+
+async function createHarness(directory: string | undefined = "/project") {
+ const timers = new Map void>();
+ const buttons = new Map();
+ const subscribed = Promise.withResolvers();
+ const state = { connected: true, bypassed: false, failed: false, decisions: [] as { name: string; count: number }[] };
+ const workspace = { directory, async refresh() { return { workspaceDirectory: workspace.directory }; } };
+ const counts = { rpc: 0, removed: 0, released: 0, unsubscribed: 0 };
+ let nextTimer = 0;
+ let updated = () => {};
+ const exports = {} as { contributeComposer(client: PluginClientContext): () => void };
+ const componentImports = new Set([
+ "react/jsx-runtime", "@getpaseo/plugin/client", "@getpaseo/plugin/client/react-native",
+ "@tanstack/react-query", "react", "react-native", "../shared/enforcement", "./brand", "./ui",
+ ]);
+ runInNewContext(compiled, {
+ exports, AbortController, console,
+ require(name: string) {
+ if (name === "../shared/contracts") return { snapshot, readConfiguration };
+ if (componentImports.has(name)) return {};
+ throw new Error(`Unexpected composer import: ${name}`);
+ },
+ setInterval(callback: () => void) { timers.set(++nextTimer, callback); return nextTimer; },
+ clearInterval(id: number) { timers.delete(id); },
+ });
+ const cleanup = exports.contributeComposer({
+ addComposerPill({ agentId, button }: PluginComposerPillContribution) {
+ buttons.set(agentId, { ...button });
+ return {
+ update(patch: Partial) { Object.assign(buttons.get(agentId)!, patch); updated(); },
+ remove() { buttons.delete(agentId); counts.removed++; },
+ };
+ },
+ async rpc(contract: { name: string }, input: { cwd: string }) {
+ counts.rpc++;
+ assert.equal(input.cwd, workspace.directory);
+ if (state.failed) throw new Error("RPC unavailable");
+ if (contract.name === snapshot.name) return { connected: state.connected, stats: { decisions: state.decisions } };
+ assert.equal(contract.name, readConfiguration.name);
+ return { effective: { softFail: state.bypassed } };
+ },
+ paseo: {
+ workspaces: { ref(id: string) { assert.equal(id, "workspace-1"); return workspace; } },
+ agents: { async list() { return { subscription: {
+ subscribe(subscription: Subscription) { subscribed.resolve(subscription); return () => { counts.unsubscribed++; }; },
+ release() { counts.released++; },
+ } }; } },
+ },
+ } as unknown as PluginClientContext);
+ const subscription = await subscribed.promise;
+ function nextUpdate() { return new Promise((resolve) => { updated = resolve; }); }
+ return {
+ buttons, timers, state, workspace, counts, cleanup, subscription, nextUpdate,
+ async poll() {
+ assert.equal(timers.size, 1);
+ const refreshed = nextUpdate();
+ for (const callback of timers.values()) callback();
+ await refreshed;
+ },
+ };
+}
+
+const firstAgent: Agent = { id: "agent-1", workspaceId: "workspace-1", provider: "codex", status: "running" };
+
+function assertStatus(button: PluginButton, label: string, summary: string) {
+ assert.equal(button.label, label);
+ assert.equal(button.title, `Be concise · ${summary}`);
+}
+
+test("composer pill shows one attention count when enabled and Off when bypassed", { timeout: 3000 }, async (t) => {
+ const harness = await createHarness();
+ t.after(harness.cleanup);
+ const firstUpdate = harness.nextUpdate();
+ harness.subscription.snapshot({ entries: [{ agent: firstAgent }] });
+ const button = harness.buttons.get(firstAgent.id)!;
+ assertStatus(button, "…", "Connecting");
+ await firstUpdate;
+ assertStatus(button, "0", "0 rejected or flagged");
+ assert.equal(typeof button.icon, "function");
+
+ harness.state.decisions = [{ name: "allow", count: 24 }, { name: "flag", count: 4 }, { name: "ask", count: 2 }, { name: "deny", count: 3 }, { name: "block", count: 2 }];
+ await harness.poll();
+ assertStatus(button, "11", "11 rejected or flagged");
+ assert.equal(typeof button.icon, "function");
+
+ harness.state.decisions.push({ name: "rewrite", count: 3 }, { name: "bypass", count: 2 }, { name: "error", count: 1 });
+ await harness.poll();
+ assertStatus(button, "11", "11 rejected or flagged");
+
+ harness.state.bypassed = true;
+ await harness.poll();
+ assertStatus(button, "Off", "Enforcement off");
+ assert.equal(typeof button.icon, "function");
+
+ harness.state.connected = false;
+ await harness.poll();
+ assertStatus(button, "…", "Connecting");
+
+ harness.state.failed = true;
+ await harness.poll();
+ assertStatus(button, "!", "Unavailable");
+
+ Object.assign(harness.state, { connected: true, bypassed: false, failed: false, decisions: [] });
+ await harness.poll();
+ assertStatus(button, "0", "0 rejected or flagged");
+ assert.equal(typeof button.icon, "function");
+});
+
+test("agents sharing a workspace inherit the current label and title and share polling", { timeout: 3000 }, async (t) => {
+ const harness = await createHarness();
+ t.after(harness.cleanup);
+ harness.state.bypassed = true;
+ harness.state.decisions = [{ name: "ask", count: 12 }];
+ const firstUpdate = harness.nextUpdate();
+ harness.subscription.snapshot({ entries: [{ agent: firstAgent }] });
+ await firstUpdate;
+ assertStatus(harness.buttons.get(firstAgent.id)!, "Off", "Enforcement off");
+
+ const secondAgent = { ...firstAgent, id: "agent-2", provider: "claude" };
+ harness.subscription.update({ type: "agent_update", payload: { kind: "upsert", agent: secondAgent } });
+ assertStatus(harness.buttons.get(secondAgent.id)!, "Off", "Enforcement off");
+ assert.equal(harness.timers.size, 1);
+ assert.equal(harness.counts.rpc, 2);
+
+ harness.state.bypassed = false;
+ await harness.poll();
+ for (const button of harness.buttons.values()) {
+ assertStatus(button, "12", "12 rejected or flagged");
+ assert.equal(typeof button.icon, "function");
+ }
+ assert.equal(harness.counts.rpc, 4);
+
+ const thirdAgent = { ...firstAgent, id: "agent-3" };
+ harness.subscription.update({ type: "agent_update", payload: { kind: "upsert", agent: thirdAgent } });
+ assertStatus(harness.buttons.get(thirdAgent.id)!, "12", "12 rejected or flagged");
+ assert.equal(typeof harness.buttons.get(thirdAgent.id)!.icon, "function");
+ harness.subscription.update({ type: "agent_update", payload: { kind: "remove", agentId: thirdAgent.id } });
+
+ harness.subscription.update({ type: "agent_update", payload: { kind: "remove", agentId: firstAgent.id } });
+ assert.equal(harness.timers.size, 1);
+ harness.subscription.update({ type: "agent_update", payload: { kind: "remove", agentId: secondAgent.id } });
+ assert.equal(harness.timers.size, 0);
+ assert.equal(harness.buttons.size, 0);
+ assert.equal(harness.counts.removed, 3);
+});
+
+test("composer stays connecting until its workspace directory is available", { timeout: 3000 }, async (t) => {
+ const harness = await createHarness();
+ t.after(harness.cleanup);
+ harness.workspace.directory = undefined;
+ const firstUpdate = harness.nextUpdate();
+ harness.subscription.snapshot({ entries: [{ agent: firstAgent }] });
+ await firstUpdate;
+ assertStatus(harness.buttons.get(firstAgent.id)!, "…", "Connecting");
+ assert.equal(harness.counts.rpc, 0);
+
+ harness.workspace.directory = "/project";
+ await harness.poll();
+ assertStatus(harness.buttons.get(firstAgent.id)!, "0", "0 rejected or flagged");
+});