diff --git a/desktop/src-tauri/src/commands/folds_exec.rs b/desktop/src-tauri/src/commands/folds_exec.rs deleted file mode 100644 index e1d60a6ab1..0000000000 --- a/desktop/src-tauri/src/commands/folds_exec.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Tauri command backing the Accumulator settings panel. -//! -//! The panel is a thin control plane over the bundled `buzz` CLI: every -//! action runs `buzz folds …` 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, plus relay reads and the publish); every other verb is relay I/O -/// only. Both leave generous headroom over the inner timeouts so the CLI's -/// own error reporting wins the race against this outer kill. -const RUN_TIMEOUT: Duration = Duration::from_secs(900); -const QUERY_TIMEOUT: Duration = Duration::from_secs(120); - -#[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::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()) -} - -/// Kill the child — on unix the entire process group it leads, so the model -/// subprocess a `folds run` spawned dies with it. -fn kill_group(child: &mut std::process::Child) { - #[cfg(unix)] - // SAFETY: plain syscall; a negative pid signals the process group. - unsafe { - libc::kill(-(child.id() as i32), libc::SIGKILL); - } - let _ = child.kill(); - let _ = child.wait(); -} - -fn read_pipe_lossy(pipe: Option) -> 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, - nsec: String, - relay_url: String, -) -> Result { - 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); - // Scrub ambient credentials the desktop process may carry: an inherited - // BUZZ_AUTH_TAG would be silently injected into every event this CLI - // signs (folds are personal, never membership-delegated). - command.env_remove("BUZZ_AUTH_TAG"); - command.env_remove("BUZZ_API_TOKEN"); - command.env_remove("NOSTR_PRIVATE_KEY"); - // 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); - } - if let Some(home) = dirs::home_dir() { - command.current_dir(home); - } - command.stdin(Stdio::null()); - command.stdout(Stdio::piped()); - command.stderr(Stdio::piped()); - // Lead a fresh process group so a timeout kill reaches the model CLI the - // fold runner spawned, not just the `buzz` wrapper. - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.process_group(0); - } - 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 { - kill_group(&mut child); - 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) => { - kill_group(&mut child); - 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 ` 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, - state: State<'_, AppState>, -) -> Result { - 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 { - 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()); - } -} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 832020cd48..c8184a0103 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -21,7 +21,6 @@ mod clipboard; mod dms; mod engrams; mod export_util; -mod folds_exec; mod global_agent_config; mod identity; mod identity_archive; @@ -95,7 +94,6 @@ 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::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 38bced0d49..12082a2a82 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -839,7 +839,6 @@ 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, diff --git a/desktop/src/features/accumulator/ui/AccumulatorSettingsCard.tsx b/desktop/src/features/accumulator/ui/AccumulatorSettingsCard.tsx deleted file mode 100644 index 9cf1134e45..0000000000 --- a/desktop/src/features/accumulator/ui/AccumulatorSettingsCard.tsx +++ /dev/null @@ -1,261 +0,0 @@ -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; - // Ready plans carry the exact half-open window the estimate priced, so Run - // can replay the same fetch instead of racing new arrivals. - since?: number; - until_exclusive?: number; -}; - -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(null); - const [error, setError] = React.useState(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(["estimate", fold.fold]), - onSuccess: (result) => { - setPlan(result); - setError(null); - }, - onError: (mutationError: Error) => setError(mutationError.message), - }); - - const runMutation = useMutation({ - // Replay exactly the window the preflight priced — arrivals since then - // stay pending for the next preflight instead of silently joining a run - // priced without them. - mutationFn: (priced: FoldPlan) => - runFoldsCliJson([ - "run", - fold.fold, - ...(priced.since !== undefined - ? ["--since", String(priced.since)] - : []), - ...(priced.until_exclusive !== undefined - ? ["--until", String(priced.until_exclusive)] - : []), - ]), - onSuccess: async (result) => { - setPlan(result); - setError(null); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: foldsListQueryKey }), - queryClient.invalidateQueries({ - queryKey: ["accumulator", "artifact", fold.fold], - }), - ]); - }, - onError: (mutationError: Error) => { - // A failed run invalidates the priced plan: Run re-locks until the - // next preflight rather than offering a stale "ready". - setPlan(null); - 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 ( - -
-

{fold.fold}

-

- {`${fold.selection} · ${fold.schema} · ${fold.model} · ${ - fold.latest_version - ? `v${fold.latest_version}, covered through ${formatTimestamp(fold.coverage_until)}` - : "never run" - }`} -

- {plan ? ( -

- {planSummary(plan)} -

- ) : null} - {error ? ( -

{error}

- ) : null} - {showArtifact ? ( -
-            {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."}
-          
- ) : null} -
-
- - - -
-
- ); -} - -export function AccumulatorSettingsCard() { - const foldsQuery = useQuery({ - queryKey: foldsListQueryKey, - queryFn: () => runFoldsCliJson(["list"]), - }); - - return ( -
- void foldsQuery.refetch()} - size="sm" - variant="outline" - > - - Refresh - - } - 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." - /> - - - {foldsQuery.isPending ? ( - -

Loading folds…

-
- ) : foldsQuery.isError ? ( - -

- {String(foldsQuery.error)} -

-
- ) : foldsQuery.data.length === 0 ? ( - -
-

No folds yet

-

- Create one from a terminal, then manage it here: -

-
-                  {"buzz folds set my-channel-digest --channel "}
-                
-
-
- ) : ( - foldsQuery.data.map((fold) => ( - - )) - )} -
-
-
- ); -} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 873fd3da7a..6b00fc8f74 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -9,7 +9,6 @@ import { Download, FlaskConical, Keyboard, - Layers, LayoutTemplate, MessagesSquare, MonitorCog, @@ -63,7 +62,6 @@ 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"; @@ -85,7 +83,6 @@ import { VoiceSettingsCard } from "./VoiceSettingsCard"; export type SettingsSection = | "profile" - | "accumulator" | "notifications" | "voice" | "experimental" @@ -106,7 +103,6 @@ export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "profile", - "accumulator", "notifications", "voice", "experimental", @@ -192,12 +188,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [ icon: LayoutTemplate, featureGate: "channel-templates", }, - { - value: "accumulator", - label: "Accumulator", - icon: Layers, - featureGate: "accumulator", - }, { value: "compute", label: "Compute", @@ -846,8 +836,6 @@ export function renderSettingsSection( return ; case "channel-templates": return ; - case "accumulator": - return ; case "compute": return ; case "appearance": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 0df43cdaee..d242faf618 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -63,7 +63,6 @@ const settingsNavGroups: Array<{ "custom-emoji", "local-archive", "channel-templates", - "accumulator", ], }, { diff --git a/desktop/src/shared/api/tauriFolds.ts b/desktop/src/shared/api/tauriFolds.ts deleted file mode 100644 index 5bb625af40..0000000000 --- a/desktop/src/shared/api/tauriFolds.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 ` (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 { - return invokeTauri("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. A - * failed `run` may put a structured salvage report on stdout (the paid-for - * model output that was refused or could not be published); its reason is - * folded into the thrown message so the UI surfaces why. - */ -export async function runFoldsCliJson(args: string[]): Promise { - const result = await runFoldsCli(args); - if (result.exitCode !== 0) { - let detail = result.stderr.trim(); - try { - const salvage = JSON.parse(result.stdout) as { reason?: string }; - if (salvage.reason && !detail.includes(salvage.reason)) { - detail = detail ? `${detail} (${salvage.reason})` : salvage.reason; - } - } catch { - // stdout was not a salvage report; stderr already tells the story. - } - throw new Error(detail || `buzz folds exited with code ${result.exitCode}`); - } - return JSON.parse(result.stdout) as T; -} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 88bd8ac1be..7ebbe97023 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -82,6 +82,7 @@ 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; + // Human-visible "new content" message kinds. Used as the unread trigger set // (sidebar badges, catch-up queries) and as the Home-feed mention query. // Reactions, edits, diffs, deletions, and system messages are deliberately diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 33d62609d4..6ddc1111b0 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -13476,16 +13476,6 @@ 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": { diff --git a/preview-features.json b/preview-features.json index f2860ad2f0..3b9de66ca8 100644 --- a/preview-features.json +++ b/preview-features.json @@ -36,12 +36,6 @@ "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"] } ] }