feat(accumulator): desktop Accumulator settings tab behind preview flag

Adds an 'accumulator' preview feature (off by default) that reveals a
Settings > Personal > Accumulator control plane. The panel is a thin
shell over the bundled buzz CLI: a new run_folds_cli Tauri command
executes 'buzz folds <verb> ...' as the desktop identity against the
active workspace relay, with the verb allow-listed to the folds group,
bounded args, drained pipes, and a hard timeout (longer for 'run',
which invokes a model). Nonzero exits return as data so the UI can
show the CLI's own error message.

The panel lists folds, prices a run via the zero-spend preflight
(Run only unlocks after Preflight prices the exact pending set), shows
the latest artifact, and points at the CLI for creation and automation
- fold state itself never lives in the desktop. Mirrors kinds
30640/4640 into shared/constants/kinds.ts and stubs run_folds_cli in
the e2e mock bridge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Riley Crane <rileycrane@squareup.com>
This commit is contained in:
Riley Crane
2026-08-28 16:42:54 -07:00
committed by Sol
parent 167d45d946
commit 84e86f085f
10 changed files with 528 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
//! Tauri command backing the Accumulator settings panel.
//!
//! The panel is a thin control plane over the bundled `buzz` CLI: every
//! action runs `buzz folds <verb> …` with the desktop identity's key and the
//! active workspace relay, and returns the CLI's output verbatim for the UI
//! to render. All fold state lives on the relay as the user's own signed
//! events — this command holds none.
use std::io::Read;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use nostr::ToBech32;
use serde::Serialize;
use tauri::State;
use crate::app_state::AppState;
use crate::managed_agents::resolve_command;
/// Verbs the panel may pass through. Everything runs under the `folds`
/// group (prepended here, never caller-supplied), so the desktop identity's
/// key is only ever handed to the accumulator control plane.
const ALLOWED_VERBS: &[&str] = &[
"set", "list", "get", "delete", "estimate", "run", "artifact", "share",
];
const MAX_ARGS: usize = 64;
const MAX_ARG_LEN: usize = 16 * 1024;
/// `folds run` invokes a model subprocess (the fold runner's own timeout is
/// 600s); every other verb is relay I/O only.
const RUN_TIMEOUT: Duration = Duration::from_secs(660);
const QUERY_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FoldsCliOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
fn validate_args(args: &[String]) -> Result<(), String> {
let Some(verb) = args.first() else {
return Err("folds command requires a verb".to_string());
};
if !ALLOWED_VERBS.contains(&verb.as_str()) {
return Err(format!("unsupported folds verb: {verb}"));
}
if args.len() > MAX_ARGS {
return Err(format!("too many arguments ({} > {MAX_ARGS})", args.len()));
}
for arg in args {
if arg.len() > MAX_ARG_LEN {
return Err(format!(
"argument too long ({} > {MAX_ARG_LEN} bytes)",
arg.len()
));
}
if arg.contains('\0') {
return Err("argument contains a NUL byte".to_string());
}
}
Ok(())
}
/// Resolve the bundled `buzz` CLI: exe-sibling sidecar first (DMG install),
/// then the general command resolver (dev builds, PATH installs).
fn resolve_buzz_cli() -> Result<std::path::PathBuf, String> {
std::env::current_exe()
.map(|path| path.with_file_name(format!("buzz{}", std::env::consts::EXE_SUFFIX)))
.ok()
.filter(|path| path.exists())
.or_else(|| resolve_command("buzz"))
.ok_or_else(|| "buzz CLI not found".to_string())
}
fn read_pipe_lossy(pipe: Option<impl Read>) -> String {
let Some(mut pipe) = pipe else {
return String::new();
};
let mut bytes = Vec::new();
let _ = pipe.read_to_end(&mut bytes);
String::from_utf8_lossy(&bytes).to_string()
}
fn run_folds_cli_blocking(
args: Vec<String>,
nsec: String,
relay_url: String,
) -> Result<FoldsCliOutput, String> {
validate_args(&args)?;
let cli_path = resolve_buzz_cli()?;
let timeout = if args[0] == "run" {
RUN_TIMEOUT
} else {
QUERY_TIMEOUT
};
let mut command = Command::new(&cli_path);
command.arg("folds").args(&args);
command.env("BUZZ_PRIVATE_KEY", &nsec);
command.env("BUZZ_RELAY_URL", &relay_url);
// The fold runner shells out to the user's model CLI (`claude` by
// default); the augmented PATH makes that resolvable from a DMG launch.
if let Some(path) = crate::managed_agents::readiness::cli_probe::augmented_path() {
command.env("PATH", path);
}
command.stdin(Stdio::null());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
crate::util::configure_no_window(&mut command);
let mut child = command
.spawn()
.map_err(|error| format!("failed to run buzz folds: {error}"))?;
// Drain the pipes on background threads so a chatty run can't deadlock
// on a full pipe while we poll for exit below.
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_thread = std::thread::spawn(move || read_pipe_lossy(stdout_pipe));
let stderr_thread = std::thread::spawn(move || read_pipe_lossy(stderr_pipe));
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if started.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return Err(format!("buzz folds timed out after {}s", timeout.as_secs()));
}
std::thread::sleep(Duration::from_millis(50));
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("failed to wait for buzz folds: {error}"));
}
}
};
Ok(FoldsCliOutput {
stdout: stdout_thread.join().unwrap_or_default(),
stderr: stderr_thread.join().unwrap_or_default(),
exit_code: status.code().unwrap_or(-1),
})
}
/// Run `buzz folds <args…>` as the desktop identity against the active
/// workspace relay. Nonzero exits are returned as data (the CLI's stderr
/// carries the actionable message); `Err` is reserved for launch failures,
/// timeouts, and validation.
#[tauri::command]
pub async fn run_folds_cli(
args: Vec<String>,
state: State<'_, AppState>,
) -> Result<FoldsCliOutput, String> {
validate_args(&args)?;
let nsec = state
.signing_keys()?
.secret_key()
.to_bech32()
.map_err(|error| format!("failed to encode signing key: {error}"))?;
let relay_url = crate::relay::relay_ws_url_with_override(&state);
tokio::task::spawn_blocking(move || run_folds_cli_blocking(args, nsec, relay_url))
.await
.map_err(|error| format!("folds task failed: {error}"))?
}
#[cfg(test)]
mod tests {
use super::*;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn rejects_empty_and_unknown_verbs() {
assert!(validate_args(&[]).is_err());
assert!(validate_args(&args(&["frobnicate"])).is_err());
// Group escape attempts are just unknown verbs.
assert!(validate_args(&args(&["--help"])).is_err());
assert!(validate_args(&args(&["mem"])).is_err());
}
#[test]
fn accepts_every_folds_verb() {
for verb in ALLOWED_VERBS {
assert!(validate_args(&args(&[verb])).is_ok(), "verb {verb}");
}
assert!(validate_args(&args(&["estimate", "my-fold", "--limit", "50"])).is_ok());
}
#[test]
fn rejects_oversized_and_nul_arguments() {
assert!(validate_args(&args(&["run", &"x".repeat(MAX_ARG_LEN + 1)])).is_err());
assert!(validate_args(&args(&["get", "bad\0name"])).is_err());
let mut many = vec!["list".to_string()];
many.extend(std::iter::repeat_n("x".to_string(), MAX_ARGS));
assert!(validate_args(&many).is_err());
}
}

View File

@@ -21,6 +21,7 @@ mod clipboard;
mod dms;
mod engrams;
mod export_util;
mod folds_exec;
mod global_agent_config;
mod identity;
mod identity_archive;
@@ -94,6 +95,7 @@ pub use channels::*;
pub use clipboard::*;
pub use dms::*;
pub use engrams::*;
pub use folds_exec::*;
pub use global_agent_config::*;
pub use identity::*;
pub use identity_archive::*;

View File

@@ -839,6 +839,7 @@ pub fn run() {
fetch_join_policy,
set_prevent_sleep_active,
get_agent_memory,
run_folds_cli,
relay_reconnect_hook,
relay_reconnect_hook_configured,
observer_archive_default_enabled,

View File

@@ -0,0 +1,252 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw } from "lucide-react";
import * as React from "react";
import { runFoldsCli, runFoldsCliJson } from "@/shared/api/tauriFolds";
import { Button } from "@/shared/ui/button";
import {
SettingsOptionGroup,
SettingsOptionGroupList,
SettingsOptionRow,
} from "@/features/settings/ui/SettingsOptionGroup";
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
// JSON shapes printed by the `buzz folds` CLI (crates/buzz-cli/commands/folds.rs).
type FoldSummary = {
fold: string;
selection: string;
schema: string;
model: string;
latest_version: number | null;
covered_signals: number;
coverage_until: number | null;
};
type FoldPlan = {
fold: string;
action: "cached" | "stalled" | "ready" | "run";
note?: string;
reason?: string;
version?: number;
pending_signals?: number;
would_show?: number;
truncated_to_budget?: boolean;
est_input_tokens?: number;
est_cost_usd?: number | null;
next_version?: number;
shown_signals?: number;
coverage_until?: number | null;
};
const foldsListQueryKey = ["accumulator", "folds"] as const;
function formatTimestamp(seconds: number | null | undefined): string {
if (!seconds) return "never";
return new Date(seconds * 1000).toLocaleString();
}
function formatCost(costUsd: number | null | undefined): string {
if (costUsd === null || costUsd === undefined) return "unknown cost";
return `~$${costUsd.toFixed(4)}`;
}
function planSummary(plan: FoldPlan): string {
switch (plan.action) {
case "cached":
return `Up to date — v${plan.version ?? "?"} already covers everything new.`;
case "stalled":
return `Stalled: ${plan.reason ?? "cannot fit the pending events"} (${plan.pending_signals ?? 0} pending).`;
case "ready":
return `${plan.pending_signals ?? 0} new signal(s); would fold ${plan.would_show ?? 0} into v${plan.next_version ?? "?"} for ${formatCost(plan.est_cost_usd)} (${plan.est_input_tokens ?? "?"} input tokens${plan.truncated_to_budget ? ", chunked to fit the context budget" : ""}).`;
case "run":
return `Folded ${plan.shown_signals ?? 0} signal(s) into v${plan.version ?? "?"} (coverage through ${formatTimestamp(plan.coverage_until)}).`;
}
}
function FoldRow({ fold }: { fold: FoldSummary }) {
const queryClient = useQueryClient();
const [plan, setPlan] = React.useState<FoldPlan | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [showArtifact, setShowArtifact] = React.useState(false);
const artifactQuery = useQuery({
queryKey: ["accumulator", "artifact", fold.fold],
queryFn: () => runFoldsCli(["artifact", fold.fold, "--raw"]),
enabled: showArtifact,
});
const estimateMutation = useMutation({
mutationFn: () => runFoldsCliJson<FoldPlan>(["estimate", fold.fold]),
onSuccess: (result) => {
setPlan(result);
setError(null);
},
onError: (mutationError: Error) => setError(mutationError.message),
});
const runMutation = useMutation({
mutationFn: () => runFoldsCliJson<FoldPlan>(["run", fold.fold]),
onSuccess: async (result) => {
setPlan(result);
setError(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: foldsListQueryKey }),
queryClient.invalidateQueries({
queryKey: ["accumulator", "artifact", fold.fold],
}),
]);
},
onError: (mutationError: Error) => setError(mutationError.message),
});
const busy = estimateMutation.isPending || runMutation.isPending;
// Priced-before-spend: Run only unlocks after a preflight priced this
// exact pending set. Anything that changes the set re-requires preflight.
const runReady = plan?.action === "ready" && !busy;
return (
<SettingsOptionRow data-testid={`accumulator-fold-${fold.fold}`}>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{fold.fold}</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
{`${fold.selection} · ${fold.schema} · ${fold.model} · ${
fold.latest_version
? `v${fold.latest_version}, covered through ${formatTimestamp(fold.coverage_until)}`
: "never run"
}`}
</p>
{plan ? (
<p className="mt-1 text-sm text-muted-foreground">
{planSummary(plan)}
</p>
) : null}
{error ? (
<p className="mt-1 text-sm text-destructive">{error}</p>
) : null}
{showArtifact ? (
<pre className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap rounded-md border border-border/50 bg-muted/30 p-3 text-xs">
{artifactQuery.isPending
? "Loading artifact…"
: artifactQuery.isError
? String(artifactQuery.error)
: artifactQuery.data.exitCode === 0
? artifactQuery.data.stdout
: artifactQuery.data.stderr.trim() ||
"This fold has no artifact yet."}
</pre>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
data-testid={`accumulator-artifact-${fold.fold}`}
onClick={() => setShowArtifact((visible) => !visible)}
size="sm"
variant="ghost"
>
{showArtifact ? "Hide digest" : "View digest"}
</Button>
<Button
data-testid={`accumulator-estimate-${fold.fold}`}
disabled={busy}
onClick={() => estimateMutation.mutate()}
size="sm"
variant="outline"
>
{estimateMutation.isPending ? "Pricing…" : "Preflight"}
</Button>
<Button
data-testid={`accumulator-run-${fold.fold}`}
disabled={!runReady}
onClick={() => runMutation.mutate()}
size="sm"
>
{runMutation.isPending
? "Folding…"
: plan?.action === "ready"
? `Run ${formatCost(plan.est_cost_usd)}`
: "Run"}
</Button>
</div>
</SettingsOptionRow>
);
}
export function AccumulatorSettingsCard() {
const foldsQuery = useQuery({
queryKey: foldsListQueryKey,
queryFn: () => runFoldsCliJson<FoldSummary[]>(["list"]),
});
return (
<section className="min-w-0" data-testid="settings-accumulator">
<SettingsSectionHeader
action={
<Button
data-testid="accumulator-refresh"
disabled={foldsQuery.isFetching}
onClick={() => void foldsQuery.refetch()}
size="sm"
variant="outline"
>
<RefreshCw
className={foldsQuery.isFetching ? "animate-spin" : undefined}
/>
Refresh
</Button>
}
title="Accumulator"
description="Standing digests over saved relay selections. Each fold keeps one always-current artifact; preflight prices a run exactly before anything is spent."
/>
<SettingsOptionGroupList>
<SettingsOptionGroup title="Folds">
{foldsQuery.isPending ? (
<SettingsOptionRow>
<p className="text-sm text-muted-foreground">Loading folds…</p>
</SettingsOptionRow>
) : foldsQuery.isError ? (
<SettingsOptionRow>
<p className="text-sm text-destructive">
{String(foldsQuery.error)}
</p>
</SettingsOptionRow>
) : foldsQuery.data.length === 0 ? (
<SettingsOptionRow data-testid="accumulator-empty">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">No folds yet</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
Create one from a terminal, then manage it here:
</p>
<pre className="mt-2 overflow-x-auto rounded-md border border-border/50 bg-muted/30 p-3 text-xs">
{"buzz folds set my-channel-digest --channel <channel-uuid>"}
</pre>
</div>
</SettingsOptionRow>
) : (
foldsQuery.data.map((fold) => (
<FoldRow fold={fold} key={fold.fold} />
))
)}
</SettingsOptionGroup>
<SettingsOptionGroup title="Automation">
<SettingsOptionRow>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
Everything here is also available to agents and scripts as the
`buzz folds` CLI (set, list, get, delete, estimate, run, artifact,
share). Scheduled runs are a workflow concern — folds deliberately
carry no cadence of their own.
</p>
</SettingsOptionRow>
</SettingsOptionGroup>
</SettingsOptionGroupList>
</section>
);
}

View File

@@ -9,6 +9,7 @@ import {
Download,
FlaskConical,
Keyboard,
Layers,
LayoutTemplate,
MessagesSquare,
MonitorCog,
@@ -62,6 +63,7 @@ import {
ThreadLayoutSetting,
} from "./AppearanceSettingsControls";
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { AccumulatorSettingsCard } from "@/features/accumulator/ui/AccumulatorSettingsCard";
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
@@ -83,6 +85,7 @@ import { VoiceSettingsCard } from "./VoiceSettingsCard";
export type SettingsSection =
| "profile"
| "accumulator"
| "notifications"
| "voice"
| "experimental"
@@ -103,6 +106,7 @@ export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
"profile",
"accumulator",
"notifications",
"voice",
"experimental",
@@ -188,6 +192,12 @@ export const settingsSections: SettingsSectionDescriptor[] = [
icon: LayoutTemplate,
featureGate: "channel-templates",
},
{
value: "accumulator",
label: "Accumulator",
icon: Layers,
featureGate: "accumulator",
},
{
value: "compute",
label: "Compute",
@@ -836,6 +846,8 @@ export function renderSettingsSection(
return <AgentsSettingsPanel />;
case "channel-templates":
return <ChannelTemplatesSettingsCard />;
case "accumulator":
return <AccumulatorSettingsCard />;
case "compute":
return <MeshComputeSettingsCard />;
case "appearance":

View File

@@ -63,6 +63,7 @@ const settingsNavGroups: Array<{
"custom-emoji",
"local-archive",
"channel-templates",
"accumulator",
],
},
{

View File

@@ -0,0 +1,31 @@
import { invokeTauri } from "@/shared/api/tauri";
/** Output of one `buzz folds` CLI invocation, verbatim. */
export type FoldsCliOutput = {
stdout: string;
stderr: string;
exitCode: number;
};
/**
* Run `buzz folds <args…>` (bundled CLI, desktop identity, active workspace
* relay). Nonzero exits resolve normally with the CLI's stderr message —
* rejection is reserved for launch failures, timeouts, and arg validation.
*/
export async function runFoldsCli(args: string[]): Promise<FoldsCliOutput> {
return invokeTauri<FoldsCliOutput>("run_folds_cli", { args });
}
/**
* Run a folds verb that prints JSON on success and parse it. Throws with the
* CLI's stderr (which carries the actionable message) on nonzero exit.
*/
export async function runFoldsCliJson<T>(args: string[]): Promise<T> {
const result = await runFoldsCli(args);
if (result.exitCode !== 0) {
throw new Error(
result.stderr.trim() || `buzz folds exited with code ${result.exitCode}`,
);
}
return JSON.parse(result.stdout) as T;
}

View File

@@ -82,6 +82,11 @@ export const KIND_GIT_STATUS_DRAFT = 1633;
// NIP-DV: relay-signed per-viewer DM visibility snapshot (d=viewer pubkey,
// h-tags = currently-hidden DM channel ids).
export const KIND_DM_VISIBILITY = 30622;
// Accumulator folds. Mirror of buzz-core's KIND_FOLD_SPEC/KIND_FOLD_ARTIFACT.
// Both author-only and NIP-44 encrypted to self; the desktop reads them via
// the bundled `buzz folds` CLI, never directly.
export const KIND_FOLD_SPEC = 30640;
export const KIND_FOLD_ARTIFACT = 4640;
// Human-visible "new content" message kinds. Used as the unread trigger set
// (sidebar badges, catch-up queries) and as the Home-feed mention query.

View File

@@ -13476,6 +13476,16 @@ export function maybeInstallE2eTauriMocks() {
payload as { runtimeId?: string },
activeConfig,
);
case "run_folds_cli": {
// Accumulator panel: default to an empty fold list so the gated
// section renders its empty state without a real CLI spawn.
const args = (payload as { args?: string[] })?.args ?? [];
return {
stdout: args[0] === "list" ? "[]" : "{}",
stderr: "",
exitCode: 0,
};
}
case "discover_backend_providers":
return activeConfig?.mock?.backendProviders ?? [];
case "probe_backend_provider": {

View File

@@ -36,6 +36,12 @@
"name": "Agent-managed profiles",
"description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy",
"platforms": ["desktop"]
},
{
"id": "accumulator",
"name": "Accumulator",
"description": "Standing fold digests over saved relay selections, run manually via buzz folds",
"platforms": ["desktop"]
}
]
}