feat: add Sprout nest — persistent agent workspace at ~/.sprout (#290)

This commit is contained in:
tlongwell-block
2026-04-10 15:01:26 -04:00
committed by GitHub
parent 489d69ffde
commit ff86e0cf49
5 changed files with 340 additions and 8 deletions
+1 -1
View File
@@ -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 -2
View File
@@ -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 || {
+25 -5
View File
@@ -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 1030 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