Skip to content
Merged
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
39 changes: 28 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ A native companion to [be-concise](https://github.com/yannelli/be-concise) for C
- Live hook decisions, searchable by tool, file, session, project, and reason. Pause the feed or open an event’s request and response.
- 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.
- User and project configuration with sections for limits, checks, style, dictionary, exceptions, and logging, plus a JSON view and test filter settings. List controls follow the be-concise merge rule for each key.
- A dictionary editor for terms to flag, with match rules, fixes, and hook and scope limits. Entries from other files can be turned off or copied in.
- A writing tuner that scans pasted samples and proposes settings they pass. Apply the proposal to a draft, review it, then save.
- An isolated playground for file writes, Codex patches, shell commands, and final replies. Previews show the decision, reason, and findings of each hook without executing the pasted command or writing the target file.
- A Plugin tab that installs, updates, downgrades, and removes be-concise for Claude Code and Codex from the be-concise GitHub releases.

## Screenshots
Expand Down Expand Up @@ -62,16 +64,33 @@ All images are 1920×1080. They come from the Paseo 0.11.0-beta.3 web app in lig

## 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.
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. The dictionary and the writing tuner need be-concise 0.10.0; controls for settings that the installed version lacks are hidden. See [be-concise 0.10 support](docs/be-concise-0.10.md). Paseo supplies all runtime dependencies.

Install the npm release:

```sh
paseo plugin add npm:@yannelli/paseo-be-concise
paseo plugin ls
```

To install from GitHub instead, run `paseo plugin add yannelli/paseo-plugin-concise`.
`paseo plugin update paseo-be-concise` shows the installed and proposed revisions before it updates. To pin a version, add it to the source, for example `npm:@yannelli/paseo-be-concise@2.3.0`.

Plugins must be enabled in Paseo Settings → Plugins. Installation runs trusted code with the daemon user’s access.

The plugin detects installed be-concise versions in the Claude and Codex caches, respecting `CLAUDE_CONFIG_DIR` and `CODEX_HOME`. For a source checkout or custom installation, set `PASEO_CONCISE_ROOT` in the daemon environment to the repository or `plugins/concise` directory, then reload this plugin.

### Older Paseo versions

Paseo installs npm sources from 0.9.0. On older Paseo, install the Git tag built for that version:

For local development:
| Paseo | Plugin tag | Command |
| --- | --- | --- |
| 0.8.0 | `v1.0.0` | `paseo plugin add yannelli/paseo-plugin-concise --ref v1.0.0` |
| 0.7.2 | `v0.2.9` | `paseo plugin add yannelli/paseo-plugin-concise --ref v0.2.9` |

These tags do not have later features, such as the Plugin tab and the be-concise 0.10 controls. On Paseo 0.9.0 or newer, `--ref` also installs a 2.x tag that is not on npm, for example `--ref v2.2.0`. See [npm publishing](docs/npm-publishing.md#paseo-versions) for the sources.

### Local development

```sh
git clone https://github.com/yannelli/paseo-plugin-concise.git
Expand All @@ -82,10 +101,6 @@ paseo plugin install "$PWD"
paseo plugin ls
```

Plugins must be enabled in Paseo Settings → Plugins. Installation runs trusted code with the daemon user’s access.

The plugin detects installed be-concise versions in the Claude and Codex caches, respecting `CLAUDE_CONFIG_DIR` and `CODEX_HOME`. For a source checkout or custom installation, set `PASEO_CONCISE_ROOT` in the daemon environment to the repository or `plugins/concise` directory, then reload this plugin.

### Install be-concise from Paseo

Open **Be concise → Plugin**, or use the panel that appears when be-concise is missing. Choose a release and press **Install**, **Update**, or **Downgrade** for Claude Code or Codex. The daemon host needs Git and the `claude` or `codex` CLI on `PATH` or in `~/.local/bin`. The plugin downloads the release from [yannelli/be-concise](https://github.com/yannelli/be-concise/releases) into `~/.local/share/paseo-be-concise/releases` and registers it as the `be-concise` marketplace. Claude Code changes use user scope. Restart Claude Code sessions after a change. For Codex, start a new session and review the hooks with `/hooks`. If the marketplace already uses another source, **Switch to GitHub releases** asks for confirmation before it points the marketplace at the release folder. Changes run in the background, and the tab shows the result when the host commands finish. See [be-concise installer](docs/plugin-installer.md) for the exact commands.
Expand All @@ -96,6 +111,8 @@ The view refreshes every two seconds. It follows the project registry and saved

Configuration controls edit the selected file and preserve its other keys. **Save changes** validates through be-concise and checks the file revision. An intervening edit preserves your draft and requires an explicit reload. The effective view uses the daemon’s environment; agent-specific environment overrides apply separately. The interface labels active and inactive layers because creating a higher-priority file changes which configuration is selected.

Session rules that be-concise sends at startup appear as **Rules sent** and do not count as flagged. The tuner reads pasted text only and writes nothing until you save.

Each preview starts with separate retry state. Preview results do not enter live activity. Only the Plugin tab changes the Claude or Codex installation, and only when you press one of its buttons. The plugin does not start another web console.

## Development
Expand All @@ -106,11 +123,11 @@ paseo plugin reload paseo-be-concise
paseo plugin logs paseo-be-concise
```

Tests use Node.js 24 and temporary home/project directories. They cover activity normalization, retention, stats, installation discovery, config conflicts, file permissions, and preview isolation. Integration tests require an installed be-concise; set `PASEO_CONCISE_ROOT` to a checkout to select it explicitly.
Tests use Node.js 24 and temporary home/project directories. They cover activity normalization, retention, stats, installation discovery, config conflicts, file permissions, preview isolation, form and dictionary edits, and the tuner. Integration tests require an installed be-concise; the tuner and dictionary tests need 0.10.0. Set `PASEO_CONCISE_ROOT` to a checkout to select it explicitly.

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, screenshot capture details, the be-concise installer commands, and npm publishing. Consult it when you change pill labels, update screenshots, change install behavior, or change the release workflow.
See the [documentation index](docs/INDEX.md) for the current composer button contract, screenshot capture details, be-concise 0.10 support, the be-concise installer commands, and npm publishing. Consult it when you change pill labels, update screenshots, add a configuration control, change install behavior, or change the release workflow.

## Releases

Expand Down
62 changes: 62 additions & 0 deletions backend.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
import test from "node:test";
import { ActivityStore, activityStats, normalizeEvent } from "./server/activity.ts";
import { createBackend, discoverInstallation, visibleEnvironment } from "./server/adapter.ts";
import { compareVersions } from "./shared/versions.ts";

const record = (extra: Record<string, unknown> = {}) => ({
cwd: "/project", hook: "check-edit", ts: "2026-09-05T10:30:00.000Z", decision: "allow",
Expand Down Expand Up @@ -68,12 +69,26 @@ test("stats count interventions, sessions, durations, and thirty minute buckets"
assert.equal(activityStats([]).averageMs, 0);
});

test("injected session rules count as context while config warnings stay flagged", () => {
const lifecycle = (response: Record<string, unknown>, decision = "flag") => normalizeEvent(record({
hook: "session-context", event: "SessionStart", decision, request: { hook_event_name: "SessionStart", session_id: "s" }, response,
}), "id")!.event;
const rules = lifecycle({ hookSpecificOutput: { additionalContext: "[concise] Active rules:" } });
assert.equal(rules.decision, "context");
assert.equal(rules.tool, "SessionStart");
assert.equal(lifecycle({ systemMessage: "[concise] config problem", hookSpecificOutput: { additionalContext: "rules" } }).decision, "flag");
assert.equal(lifecycle({}, "bypass").decision, "bypass");
assert.equal(normalizeEvent(record({ decision: "flag", response: { systemMessage: "kept" } }), "id")!.event.decision, "flag");
assert.equal(activityStats([rules]).interventions, 0);
});

test("environment output includes valid supported flags and excludes arbitrary values", () => {
assert.deepEqual(visibleEnvironment({
BEC_MONITOR_PERSIST: "0", BEC_FEATURE_ALWAYS_ENABLE: "aiWriting,comments", BEC_LOG_MAX_SIZE: "5m",
BEC_CONFIG_JSON: '{"secret":"hidden"}', BEC_CONFIG_PATH: "/private/path", BEC_API_KEY: "secret",
BEC_LOG_ENABLED: "secret", BEC_FEATURE_DISABLE: "api-token", BEC_ALLOW_PHRASES: "private phrase",
}), { BEC_MONITOR_PERSIST: "0", BEC_FEATURE_ALWAYS_ENABLE: "aiWriting,comments", BEC_LOG_MAX_SIZE: "5m" });
assert.deepEqual(visibleEnvironment({ BEC_FEATURE_ENABLE: "dictionary,emDash" }), { BEC_FEATURE_ENABLE: "dictionary,emDash" });
});

async function fixture(t: test.TestContext) {
Expand Down Expand Up @@ -205,3 +220,50 @@ test("preview isolates sessions and leaves pasted commands and project files une
await assert.rejects(stat(join(cwd, "paseo-preview-marker")), { code: "ENOENT" });
assert.equal((await backend.getSnapshot()).events.length, 0);
});

test("the tuner requires be-concise 0.10.0 and proposes settings from pasted samples", async (t) => {
const { root, cwd, env } = await fixture(t);
await fakeInstallation(join(root, "missing"), "0.9.0");
const older = createBackend(env);
t.after(older.close);
await assert.rejects(older.runTune({ cwd, kind: "docs", texts: ["Text."] }), /requires be-concise 0\.10\.0 or newer\. This host has 0\.9\.0/);
const installed = await discoverInstallation(process.env);
if (!installed || compareVersions(installed.version, "0.10.0") < 0) return t.skip("be-concise >=0.10.0 is required for tuner coverage");
const backend = createBackend({ ...env, PASEO_CONCISE_ROOT: installed.root });
t.after(backend.close);
const sample = "The parser reads each file \u2014 then it writes the index. ".repeat(40);
const result = await backend.runTune({ cwd, kind: "docs", texts: [sample, sample] });
assert.equal(result.samples, 2);
assert.ok(result.words > 300);
assert.ok(result.evidence.every((item) => typeof item.key === "string" && typeof item.reason === "string"));
assert.equal(typeof result.delta, "object");
await assert.rejects(stat(join(cwd, ".claude/concise.json")), { code: "ENOENT" });
await assert.rejects(backend.runTune({ cwd, kind: "essay" as "docs", texts: ["Text."] }), /^Error: unknown kind "essay"/);
});

test("settings written by the 0.10 controls pass upstream validation and name the bad entry", async (t) => {
const installed = await discoverInstallation(process.env);
if (!installed || compareVersions(installed.version, "0.10.0") < 0) return t.skip("be-concise >=0.10.0 is required for 0.10 settings coverage");
const { cwd, env } = await fixture(t);
env.PASEO_CONCISE_ROOT = installed.root;
const backend = createBackend(env);
t.after(backend.close);
const layer = {
context: { perTurn: true }, subagentStop: { exemptAgentTypes: ["Explore"] }, testFilter: { codexPostToolUse: true },
features: { dictionary: { mode: "deny", entries: [
{ id: "blacklist", match: "exact", value: "blacklist", fix: "denylist" },
{ id: "ticket", match: "regex", value: "\\bJIRA-\\d+\\b", fix: "link the ticket", flags: "i", scopes: ["commit"] },
] } },
ignoreGlobs: ["**/dist/**"], allowList: { phrases: ["load-bearing"] }, log: { rotate: "daily", maxSize: "1m", maxFiles: 2 },
};
const saved = await backend.saveConfiguration({ cwd, id: "project-claude", text: JSON.stringify(layer), revision: null });
const effective = saved.effective as typeof layer & { problems: unknown[] };
assert.equal(effective.context.perTurn, true);
assert.deepEqual(effective.subagentStop.exemptAgentTypes, ["Explore"]);
assert.deepEqual(effective.features.dictionary.entries.map(({ id }) => id), ["blacklist", "ticket"]);
assert.deepEqual(effective.ignoreGlobs, ["**/dist/**"]);
assert.deepEqual(effective.problems, []);
const revision = saved.layers.find(({ id }) => id === "project-claude")!.revision;
const broken = { features: { dictionary: { entries: [{ id: "ok", match: "exact", value: "a", fix: "b" }, { id: "bad", match: "regex", value: "(", fix: "c" }] } } };
await assert.rejects(backend.saveConfiguration({ cwd, id: "project-claude", text: JSON.stringify(broken), revision }), /features\.dictionary\.entries\[1\]: value does not compile/);
});
22 changes: 17 additions & 5 deletions client/activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@ import { Pressable, Text, TextInput, View } from "react-native";
import { type ActivityEvent, type ActivityStats, eventDetail } from "../shared/contracts";
import { Button, Card, decisionColor, Label, Row } from "./ui";

const filters = [
const ATTENTION = ["deny", "ask", "block", "error"];
const knownFilters = [
{ value: "all", label: "All decisions" }, { value: "attention", label: "Needs attention" }, { value: "allow", label: "Allowed" },
{ value: "flag", label: "Flagged" }, { value: "rewrite", label: "Rewritten" }, { value: "bypass", label: "Bypassed" },
{ value: "flag", label: "Flagged" }, { value: "rewrite", label: "Rewritten" }, { value: "filter", label: "Filtered" },
{ value: "bypass", label: "Bypassed" }, { value: "context", label: "Rules sent" },
];

function decisionFilters(stats: ActivityStats) {
const count = (names: string[]) => stats.decisions.reduce((sum, item) => sum + (names.includes(item.name) ? item.count : 0), 0);
const known = new Set([...knownFilters.map((item) => item.value), ...ATTENTION]);
const extra = stats.decisions.filter((item) => !known.has(item.name)).map((item) => ({ value: item.name, label: item.name }));
return [...knownFilters, ...extra].map((item) => ({ ...item,
count: item.value === "all" ? stats.total : count(item.value === "attention" ? ATTENTION : [item.value]) }));
}

export function Activity({ theme, compact, events, stats, paused, onPause }: {
theme: PluginTheme; compact: boolean; events: ActivityEvent[]; stats: ActivityStats; paused: boolean; onPause: () => void;
}) {
Expand All @@ -20,8 +30,9 @@ export function Activity({ theme, compact, events, stats, paused, onPause }: {
const [limit, setLimit] = useState(60);
const [statsOpen, setStatsOpen] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const filters = decisionFilters(stats);
const visible = useMemo(() => events.filter((event) =>
(filter === "all" || (filter === "attention" ? ["deny", "ask", "block", "error"].includes(event.decision) : event.decision === filter)) &&
(filter === "all" || (filter === "attention" ? ATTENTION.includes(event.decision) : event.decision === filter)) &&
`${event.tool} ${event.hook} ${event.target} ${event.summary} ${event.session} ${event.projectName}`.toLowerCase().includes(search.toLowerCase())
), [events, filter, search]);
const maximum = Math.max(1, ...stats.minutes.map((minute) => minute.count));
Expand Down Expand Up @@ -64,7 +75,7 @@ export function Activity({ theme, compact, events, stats, paused, onPause }: {
<Pressable accessibilityRole="button" accessibilityLabel="Filter activity" accessibilityState={{ expanded: filtersOpen }} onPress={() => setFiltersOpen(!filtersOpen)}
style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 6, minHeight: 30, paddingHorizontal: 8, borderWidth: 1, borderColor: theme.colors.border, borderRadius: 6 }}>
<Icon name="ListFilter" size={13} color={theme.colors.foregroundMuted} />
<Label theme={theme} size={12}>{filters.find((item) => item.value === filter)?.label}</Label>
<Label theme={theme} size={12}>{filters.find((item) => item.value === filter)?.label ?? filter}</Label>
<Icon name={filtersOpen ? "ChevronUp" : "ChevronDown"} size={12} color={theme.colors.foregroundMuted} />
</Pressable>
{filtersOpen && <View style={{ borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, padding: 4, backgroundColor: theme.colors.surface1 }}>
Expand All @@ -73,7 +84,8 @@ export function Activity({ theme, compact, events, stats, paused, onPause }: {
style={({ pressed }) => ({ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, minHeight: 30, borderRadius: 4,
backgroundColor: pressed || filter === item.value ? theme.colors.surface2 : "transparent" })}>
<View style={{ width: 14 }}>{filter === item.value && <Icon name="Check" size={14} color={theme.colors.foreground} />}</View>
<Label theme={theme} size={12}>{item.label}</Label>
<View style={{ flex: 1, minWidth: 0 }}><Label theme={theme} size={12}>{item.label}</Label></View>
<Text style={{ color: filter === item.value ? theme.colors.foreground : theme.colors.foregroundMuted, fontSize: 12, fontVariant: ["tabular-nums"] }}>{item.count}</Text>
</Pressable>)}
</View>}
</View>
Expand Down
1 change: 1 addition & 0 deletions client/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const decisionGroups = [
{ label: "rejected", names: ["deny", "block"], decision: "deny" },
{ label: "flagged", names: ["ask", "flag"], decision: "flag" },
{ label: "rewritten", names: ["rewrite"], decision: "rewrite" },
{ label: "filtered", names: ["filter"], decision: "filter" },
{ label: "bypassed", names: ["bypass"], decision: "bypass" },
{ label: "errors", names: ["error"], decision: "error" },
];
Expand Down
Loading
Loading