mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add Sprout nest — persistent agent workspace at ~/.sprout (#290)
This commit is contained in:
@@ -42,7 +42,7 @@ const overrides = new Map([
|
||||
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
|
||||
["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore
|
||||
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
|
||||
["src-tauri/src/lib.rs", 550], // sprout-media:// proxy now forwards Range headers + propagates Content-Range/Accept-Ranges for video seeking
|
||||
["src-tauri/src/lib.rs", 560], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup()
|
||||
["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
|
||||
["src-tauri/src/commands/agents.rs", 860], // remote agent lifecycle routing (local + provider branches) + scope enforcement + mcp_toolsets field; rustfmt adds line breaks around long tuple/closure blocks
|
||||
["src-tauri/src/managed_agents/runtime.rs", 650], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle
|
||||
|
||||
@@ -10,8 +10,9 @@ mod util;
|
||||
use app_state::{build_app_state, resolve_persisted_identity, AppState};
|
||||
use commands::*;
|
||||
use managed_agents::{
|
||||
find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, save_managed_agents,
|
||||
start_managed_agent_process, sync_managed_agent_processes, BackendKind, ManagedAgentProcess,
|
||||
ensure_nest, find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents,
|
||||
save_managed_agents, start_managed_agent_process, sync_managed_agent_processes, BackendKind,
|
||||
ManagedAgentProcess,
|
||||
};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -387,6 +388,13 @@ pub fn run() {
|
||||
resolve_persisted_identity(&app_handle, &state)
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
||||
|
||||
// Create the Sprout nest (~/.sprout) before agents are restored,
|
||||
// so default_agent_workdir() resolves to the nest directory.
|
||||
// Non-fatal: agents fall back to $HOME if nest creation fails.
|
||||
if let Err(error) = ensure_nest() {
|
||||
eprintln!("sprout-desktop: failed to create nest: {error}");
|
||||
}
|
||||
|
||||
// Keep launch-time agent restoration off the synchronous setup path
|
||||
// so the frontend can mount and reveal the window promptly.
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod backend;
|
||||
mod discovery;
|
||||
mod nest;
|
||||
mod persona_avatars;
|
||||
mod persona_card;
|
||||
mod personas;
|
||||
@@ -10,6 +11,7 @@ mod types;
|
||||
|
||||
pub use backend::*;
|
||||
pub use discovery::*;
|
||||
pub use nest::*;
|
||||
pub use persona_card::*;
|
||||
pub use personas::*;
|
||||
pub use runtime::*;
|
||||
@@ -17,16 +19,34 @@ pub use storage::*;
|
||||
pub use teams::*;
|
||||
pub use types::*;
|
||||
|
||||
/// Returns the user's home directory if it can be resolved and exists.
|
||||
/// Used as the default working directory for spawned agent processes.
|
||||
/// Returns the Sprout nest directory (`~/.sprout`) if it exists as a real
|
||||
/// directory (not a symlink), falling back to the user's home directory.
|
||||
///
|
||||
/// Cached for the process lifetime — home directory doesn't change at runtime.
|
||||
/// Used as the default working directory for spawned agent processes.
|
||||
/// `ensure_nest()` must be called during app setup before this is first
|
||||
/// invoked, so that `~/.sprout` exists and gets cached.
|
||||
///
|
||||
/// Cached for the process lifetime via `OnceLock`.
|
||||
/// Returns `None` in sandboxed/containerized environments where `$HOME` is
|
||||
/// unset or points to a non-existent path; callers fall back to inheriting
|
||||
/// the parent's CWD.
|
||||
pub fn default_agent_workdir() -> Option<std::path::PathBuf> {
|
||||
use std::sync::OnceLock;
|
||||
static HOME: OnceLock<Option<std::path::PathBuf>> = OnceLock::new();
|
||||
HOME.get_or_init(|| dirs::home_dir().filter(|p| p.is_dir()))
|
||||
static WORKDIR: OnceLock<Option<std::path::PathBuf>> = OnceLock::new();
|
||||
WORKDIR
|
||||
.get_or_init(|| {
|
||||
// Prefer ~/.sprout if it exists (created by ensure_nest()).
|
||||
// Reject symlinks to prevent redirect attacks — is_dir()
|
||||
// follows symlinks, so check symlink_metadata() first.
|
||||
// Fall back to $HOME for resilience.
|
||||
nest_dir()
|
||||
.filter(|p| is_real_dir(p))
|
||||
.or_else(|| dirs::home_dir().filter(|p| p.is_dir()))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Returns `true` if `path` is a real directory (not a symlink).
|
||||
fn is_real_dir(path: &std::path::Path) -> bool {
|
||||
path.symlink_metadata().map(|m| m.is_dir()).unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Sprout Nest — persistent agent workspace at `~/.sprout`.
|
||||
//!
|
||||
//! Creates a shared knowledge directory on first launch so every
|
||||
//! Sprout-spawned agent starts with orientation (AGENTS.md) and a
|
||||
//! place to accumulate research, plans, and logs across sessions.
|
||||
//!
|
||||
//! Idempotent: existing files and directories are never overwritten.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Subdirectories created inside the nest.
|
||||
const NEST_DIRS: &[&str] = &[
|
||||
"GUIDES",
|
||||
"RESEARCH",
|
||||
"PLANS",
|
||||
"WORK_LOGS",
|
||||
"REPOS",
|
||||
"OUTBOX",
|
||||
".scratch",
|
||||
];
|
||||
|
||||
/// Default AGENTS.md content written on first init.
|
||||
/// Fully static — no runtime interpolation, no secrets, no user paths.
|
||||
const AGENTS_MD: &str = include_str!("nest_agents.md");
|
||||
|
||||
/// Returns the nest root path (`~/.sprout`), or `None` if the home
|
||||
/// directory cannot be resolved.
|
||||
pub fn nest_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".sprout"))
|
||||
}
|
||||
|
||||
/// Creates the Sprout nest at `~/.sprout` if it doesn't already exist.
|
||||
///
|
||||
/// Delegates to [`ensure_nest_at`] with the resolved nest directory.
|
||||
/// Returns an error string if the home directory cannot be resolved.
|
||||
pub fn ensure_nest() -> Result<(), String> {
|
||||
let root = nest_dir().ok_or("cannot resolve home directory for nest")?;
|
||||
ensure_nest_at(&root)
|
||||
}
|
||||
|
||||
/// Creates a Sprout nest at the given `root` path.
|
||||
///
|
||||
/// - Creates the root directory and all subdirectories.
|
||||
/// - Writes `AGENTS.md` only if it doesn't already exist.
|
||||
/// - Sets 700 permissions on the root and all subdirectories (Unix).
|
||||
///
|
||||
/// Idempotent: safe to call on every launch. Existing files are never
|
||||
/// overwritten — users can freely edit AGENTS.md and it will persist.
|
||||
///
|
||||
/// Rejects symlinks at the root path to prevent redirect attacks.
|
||||
///
|
||||
/// Errors are returned as strings for Tauri compatibility; callers
|
||||
/// should log and continue rather than aborting app startup.
|
||||
pub fn ensure_nest_at(root: &Path) -> Result<(), String> {
|
||||
// Reject symlinks — we want a real directory, not a redirect.
|
||||
// Platform-independent: symlink_metadata works on all OS.
|
||||
if root
|
||||
.symlink_metadata()
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(format!(
|
||||
"{} is a symlink; refusing to use as nest root",
|
||||
root.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Create root and all subdirectories. create_dir_all is idempotent —
|
||||
// it succeeds silently if the directory already exists.
|
||||
fs::create_dir_all(root).map_err(|e| format!("create {}: {e}", root.display()))?;
|
||||
|
||||
for dir in NEST_DIRS {
|
||||
let path = root.join(dir);
|
||||
fs::create_dir_all(&path).map_err(|e| format!("create {}: {e}", path.display()))?;
|
||||
}
|
||||
|
||||
// Write AGENTS.md only if it doesn't already exist.
|
||||
// Uses create_new (O_CREAT|O_EXCL) to atomically check-and-create,
|
||||
// closing the TOCTOU gap that exists() + write() would leave open.
|
||||
// Also guarantees we never clobber a user-edited file.
|
||||
let agents_md = root.join("AGENTS.md");
|
||||
match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&agents_md)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
use std::io::Write;
|
||||
file.write_all(AGENTS_MD.as_bytes())
|
||||
.map_err(|e| format!("write {}: {e}", agents_md.display()))?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
// File already exists — leave it alone (idempotent).
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("create {}: {e}", agents_md.display()));
|
||||
}
|
||||
}
|
||||
|
||||
// Set owner-only permissions on root and all subdirectories.
|
||||
// Skip any path that is a symlink — chmod would affect the target.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = fs::Permissions::from_mode(0o700);
|
||||
fs::set_permissions(root, perms.clone())
|
||||
.map_err(|e| format!("set permissions on {}: {e}", root.display()))?;
|
||||
for dir in NEST_DIRS {
|
||||
let path = root.join(dir);
|
||||
let is_symlink = path
|
||||
.symlink_metadata()
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false);
|
||||
if !is_symlink {
|
||||
fs::set_permissions(&path, perms.clone())
|
||||
.map_err(|e| format!("set permissions on {}: {e}", path.display()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nest_dir_is_under_home() {
|
||||
if let Some(dir) = nest_dir() {
|
||||
assert!(dir.ends_with(".sprout"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_creates_all_dirs_and_agents_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".sprout");
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// All subdirectories exist.
|
||||
for dir in NEST_DIRS {
|
||||
assert!(root.join(dir).is_dir(), "{dir}/ should exist");
|
||||
}
|
||||
|
||||
// AGENTS.md was written with default content.
|
||||
let content = fs::read_to_string(root.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(content, AGENTS_MD);
|
||||
|
||||
// Permissions are 700 on Unix for root and all subdirs.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(&root).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700, "root should be 700");
|
||||
for dir in NEST_DIRS {
|
||||
let mode = fs::metadata(root.join(dir)).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700, "{dir}/ should be 700");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_is_idempotent_and_preserves_custom_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".sprout");
|
||||
|
||||
// First call creates everything.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// User customizes AGENTS.md.
|
||||
let agents = root.join("AGENTS.md");
|
||||
fs::write(&agents, "my custom instructions").unwrap();
|
||||
|
||||
// Second call succeeds and does not overwrite.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
"my custom instructions"
|
||||
);
|
||||
|
||||
// All dirs still exist.
|
||||
for dir in NEST_DIRS {
|
||||
assert!(root.join(dir).is_dir(), "{dir}/ should still exist");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_nest_rejects_symlink_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let target = tmp.path().join("real_dir");
|
||||
fs::create_dir(&target).unwrap();
|
||||
let link = tmp.path().join(".sprout");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
let result = ensure_nest_at(&link);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("symlink"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_nest_skips_permissions_on_symlinked_child() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".sprout");
|
||||
|
||||
// First call creates the real nest.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Replace REPOS/ with a symlink to an external directory.
|
||||
let external = tmp.path().join("external");
|
||||
fs::create_dir(&external).unwrap();
|
||||
fs::set_permissions(&external, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::remove_dir(&root.join("REPOS")).unwrap();
|
||||
std::os::unix::fs::symlink(&external, &root.join("REPOS")).unwrap();
|
||||
|
||||
// Second call should succeed — it skips chmod on the symlinked child.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// The external directory's permissions should be unchanged (755, not 700).
|
||||
let mode = fs::metadata(&external).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(
|
||||
mode, 0o755,
|
||||
"symlinked child's target should not be chmod'd"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# Sprout Nest
|
||||
|
||||
Your persistent workspace. Created once by the Sprout desktop app — never overwritten. Edit freely.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
| Dir | Purpose |
|
||||
|-----|---------|
|
||||
| `GUIDES/` | Actionable runbooks synthesized from research |
|
||||
| `PLANS/` | Planning documents for work in progress |
|
||||
| `RESEARCH/` | Findings, notes, and reference material |
|
||||
| `WORK_LOGS/` | Session logs — what was tried, learned, decided |
|
||||
| `OUTBOX/` | Shareable docs for external readers (no frontmatter) |
|
||||
| `REPOS/` | Cloned repositories (clone freely here for exploration) |
|
||||
| `.scratch/` | Temporary working files — treat as disposable between sessions |
|
||||
|
||||
Filenames: `ALL_CAPS_WITH_UNDERSCORES.md` (e.g., `OAUTH_FLOW_NOTES.md`).
|
||||
|
||||
## Communicating via Sprout
|
||||
|
||||
You have MCP tools for channels. Use them.
|
||||
|
||||
**Read messages:**
|
||||
- `get_messages(channel_id, limit=50)` — recent history (max 200)
|
||||
- `get_thread(channel_id, event_id)` — drill into a thread
|
||||
- `get_feed()` — personalized: your mentions, needs-action items
|
||||
|
||||
**Post messages:**
|
||||
- `send_message(channel_id, content)` — new message
|
||||
- `send_message(channel_id, content, parent_event_id)` — threaded reply
|
||||
|
||||
**Poll for new messages** (no push — poll with sleep):
|
||||
- Call `get_messages(channel_id, since=<last_seen_unix_ts>)` where the value is the `created_at` timestamp of the last message you saw
|
||||
- When `since` is set without `before`, results are **oldest-first** (chronological)
|
||||
- Sleep 10–30 seconds between polls
|
||||
|
||||
**Search:**
|
||||
- `search(q="your query")` — searches across all channels
|
||||
|
||||
## Recovering Context on Startup
|
||||
|
||||
1. Call `get_feed()` — surface mentions and items needing your action
|
||||
2. Call `get_messages` on your assigned channel(s) to read recent history
|
||||
3. Check `RESEARCH/`, `PLANS/`, `GUIDES/` before researching from scratch
|
||||
|
||||
## Knowledge File Conventions
|
||||
|
||||
Files in `GUIDES/`, `PLANS/`, `RESEARCH/`, `WORK_LOGS/` should include YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "Always Quoted Title"
|
||||
tags: [lowercase-hyphenated]
|
||||
status: active
|
||||
created: 2026-01-15
|
||||
---
|
||||
```
|
||||
|
||||
**Status values:** `active` | `superseded` | `stale` | `draft`
|
||||
|
||||
> ⚠️ Title **must** be quoted — unquoted colons can break YAML parsing.
|
||||
|
||||
## Core Guidelines
|
||||
|
||||
- **Local first** — check `RESEARCH/`, `GUIDES/`, `PLANS/` before external searches
|
||||
- **Write findings down** — if you research something, save it to `RESEARCH/`
|
||||
- **Cite sources** — no claim without a path, link, or reference
|
||||
- **Don't overwrite** — append or create new files; don't silently clobber existing work
|
||||
- **`.scratch/` is disposable** — don't rely on it across sessions
|
||||
- **Never push without approval** — do not `git push` to any remote
|
||||
- **Stay on task** — only stage files relevant to your current work
|
||||
Reference in New Issue
Block a user