diff --git a/CLAUDE.md b/CLAUDE.md
index bcbe504cb..a84abdd80 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -363,6 +363,20 @@ bd automatically syncs via Dolt:
- Use `bd dolt push`/`bd dolt pull` for remote sync
- No manual export/import needed!
+### Worktrees
+
+If you create a git worktree with plain `git worktree add`, Beads will not
+automatically share the main checkout's `.beads` state. For an existing
+worktree, run:
+
+```bash
+./scripts/bd-worktree-attach.sh
+```
+
+This writes `.beads/redirect` so the worktree uses the main repository's Beads
+database. If you create worktrees through `bd worktree create`, it should set
+up the redirect for you automatically.
+
### Important Rules
- ✅ Use bd for ALL task tracking
diff --git a/Cargo.lock b/Cargo.lock
index 5061b3900..7b828f96a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6659,6 +6659,7 @@ dependencies = [
"tempfile",
"tokio",
"tracing",
+ "walkdir",
]
[[package]]
diff --git a/README.md b/README.md
index 8b0c43023..021a822d8 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-# Moltis — A Rust-native claw you can trust
+# Moltis — A secure persistent personal agent server in Rust
One binary — sandboxed, secure, yours.
@@ -25,7 +25,7 @@ Moltis recently hit [the front page of Hacker News](https://news.ycombinator.com
**Your hardware** — Runs on a Mac Mini, a Raspberry Pi, or any server you own. One Rust binary, no Node.js, no npm, no runtime.
-**Full-featured** — Voice, memory, scheduling, Telegram, Discord, browser automation, MCP servers — all built-in. No plugin marketplace to get supply-chain attacked through.
+**Full-featured** — Voice, memory, cross-session recall, automatic edit checkpoints, scheduling, Telegram, Discord, browser automation, MCP servers, SSH or node-backed remote exec, managed deploy keys with host pinning in the web UI, a live Settings → Tools inventory, Cursor-compatible project context, and context-file threat scanning — all built-in. No plugin marketplace to get supply-chain attacked through.
**Auditable** — The agent loop + provider model fits in ~5K lines. The core (excluding the optional web UI) is ~196K lines across 46 modular crates you can audit independently, with 3,100+ tests and zero `unsafe` code\*.
@@ -123,16 +123,17 @@ See [Security Architecture](https://docs.moltis.org/security.html) for details.
- **AI Gateway** — Multi-provider LLM support (OpenAI Codex, GitHub Copilot, Local), streaming responses, agent loop with sub-agent delegation, parallel tool execution
- **Communication** — Web UI, Telegram, Microsoft Teams, Discord, API access, voice I/O (8 TTS + 7 STT providers), mobile PWA with push notifications
-- **Memory & Context** — Per-agent memory workspaces, embeddings-powered long-term memory, hybrid vector + full-text search, session persistence with auto-compaction, project context
+- **Memory & Recall** — Per-agent memory workspaces, embeddings-powered long-term memory, hybrid vector + full-text search, session persistence with auto-compaction, cross-session recall, Cursor-compatible project context, context-file safety scanning
+- **Safer Agent Editing** — Automatic checkpoints before built-in skill and memory mutations, restore tooling, session branching
- **Extensibility** — MCP servers (stdio + HTTP/SSE), skill system, 15 lifecycle hook events with circuit breaker, destructive command guard
- **Security** — Encryption-at-rest vault (XChaCha20-Poly1305 + Argon2id), password + passkey + API key auth, sandbox isolation, SSRF/CSWSH protection
-- **Operations** — Cron scheduling, OpenTelemetry tracing, Prometheus metrics, cloud deploy (Fly.io, DigitalOcean), Tailscale integration
+- **Operations** — Cron scheduling, OpenTelemetry tracing, Prometheus metrics, cloud deploy (Fly.io, DigitalOcean), Tailscale integration, managed SSH deploy keys, host-pinned remote targets, live tool inventory in Settings, and CLI/web remote-exec doctor flows
## How It Works
-Moltis is a **local-first AI gateway** — a single Rust binary that sits
-between you and multiple LLM providers. Everything runs on your machine; no
-cloud relay required.
+Moltis is a **local-first persistent agent server** — a single Rust binary that
+sits between you and multiple LLM providers, keeps durable session state, and
+can meet you across channels without handing your data to a cloud relay.
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
diff --git a/crates/agents/src/memory_writer.rs b/crates/agents/src/memory_writer.rs
index 960d54bdd..728086bea 100644
--- a/crates/agents/src/memory_writer.rs
+++ b/crates/agents/src/memory_writer.rs
@@ -14,6 +14,8 @@ pub struct MemoryWriteResult {
pub location: String,
/// Total number of bytes written.
pub bytes_written: usize,
+ /// Automatic checkpoint ID created before the write, when available.
+ pub checkpoint_id: Option,
}
/// Writes content to memory files with validation.
diff --git a/crates/agents/src/silent_turn.rs b/crates/agents/src/silent_turn.rs
index 5f12572de..112c3c0ff 100644
--- a/crates/agents/src/silent_turn.rs
+++ b/crates/agents/src/silent_turn.rs
@@ -103,6 +103,7 @@ impl AgentTool for MemoryWriteFileTool {
let MemoryWriteResult {
location,
bytes_written,
+ checkpoint_id,
} = self.writer.write_memory(path_str, content, append).await?;
self.written_paths
@@ -111,7 +112,11 @@ impl AgentTool for MemoryWriteFileTool {
.push(PathBuf::from(&location));
debug!(location = %location, bytes = bytes_written, "silent memory turn: wrote file");
- Ok(serde_json::json!({ "ok": true, "path": location }))
+ Ok(serde_json::json!({
+ "ok": true,
+ "path": location,
+ "checkpointId": checkpoint_id,
+ }))
}
}
@@ -310,6 +315,7 @@ mod tests {
Ok(MemoryWriteResult {
location: path.to_string_lossy().into_owned(),
bytes_written: bytes,
+ checkpoint_id: None,
})
}
}
diff --git a/crates/auth/src/credential_store.rs b/crates/auth/src/credential_store.rs
index 3da6a0870..1c6218e16 100644
--- a/crates/auth/src/credential_store.rs
+++ b/crates/auth/src/credential_store.rs
@@ -9,7 +9,7 @@ use {
PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng,
},
},
- secrecy::ExposeSecret,
+ secrecy::{ExposeSecret, Secret},
serde::{Deserialize, Serialize},
sha2::{Digest, Sha256},
sqlx::SqlitePool,
@@ -82,6 +82,70 @@ pub struct EnvVarEntry {
pub encrypted: bool,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SshAuthMode {
+ System,
+ Managed,
+}
+
+impl SshAuthMode {
+ fn as_db_str(self) -> &'static str {
+ match self {
+ Self::System => "system",
+ Self::Managed => "managed",
+ }
+ }
+
+ fn parse_db(value: &str) -> anyhow::Result {
+ match value {
+ "system" => Ok(Self::System),
+ "managed" => Ok(Self::Managed),
+ _ => anyhow::bail!("unknown ssh auth mode '{value}'"),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SshKeyEntry {
+ pub id: i64,
+ pub name: String,
+ pub public_key: String,
+ pub fingerprint: String,
+ pub created_at: String,
+ pub updated_at: String,
+ pub encrypted: bool,
+ pub target_count: i64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SshTargetEntry {
+ pub id: i64,
+ pub label: String,
+ pub target: String,
+ pub port: Option,
+ pub known_host: Option,
+ pub auth_mode: SshAuthMode,
+ pub key_id: Option,
+ pub key_name: Option,
+ pub is_default: bool,
+ pub created_at: String,
+ pub updated_at: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct SshResolvedTarget {
+ pub id: i64,
+ pub node_id: String,
+ pub label: String,
+ pub target: String,
+ pub port: Option,
+ pub known_host: Option,
+ pub auth_mode: SshAuthMode,
+ pub key_id: Option,
+ pub key_name: Option,
+}
+
// ── Credential store ─────────────────────────────────────────────────────────
/// Single-user credential store backed by SQLite.
@@ -241,6 +305,39 @@ impl CredentialStore {
.execute(&self.pool)
.await?;
+ sqlx::query(
+ "CREATE TABLE IF NOT EXISTS ssh_keys (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ private_key TEXT NOT NULL,
+ public_key TEXT NOT NULL,
+ fingerprint TEXT NOT NULL,
+ encrypted INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )",
+ )
+ .execute(&self.pool)
+ .await?;
+
+ sqlx::query(
+ "CREATE TABLE IF NOT EXISTS ssh_targets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ label TEXT NOT NULL UNIQUE,
+ target TEXT NOT NULL,
+ port INTEGER,
+ known_host TEXT,
+ auth_mode TEXT NOT NULL DEFAULT 'system',
+ key_id INTEGER,
+ is_default INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY(key_id) REFERENCES ssh_keys(id)
+ )",
+ )
+ .execute(&self.pool)
+ .await?;
+
sqlx::query(
"CREATE TABLE IF NOT EXISTS auth_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -627,13 +724,511 @@ impl CredentialStore {
}
};
#[cfg(not(feature = "vault"))]
- let plaintext = value;
+ let plaintext = {
+ let _ = encrypted;
+ value
+ };
result.push((key, plaintext));
}
Ok(result)
}
+ // ── Managed SSH Keys / Targets ──────────────────────────────────────
+
+ pub async fn list_ssh_keys(&self) -> anyhow::Result> {
+ let rows: Vec<(i64, String, String, String, String, String, i64, i64)> = sqlx::query_as(
+ "SELECT
+ k.id,
+ k.name,
+ k.public_key,
+ k.fingerprint,
+ strftime('%Y-%m-%dT%H:%M:%SZ', k.created_at),
+ strftime('%Y-%m-%dT%H:%M:%SZ', k.updated_at),
+ COALESCE(k.encrypted, 0),
+ COUNT(t.id)
+ FROM ssh_keys k
+ LEFT JOIN ssh_targets t ON t.key_id = k.id
+ GROUP BY k.id, k.name, k.public_key, k.fingerprint, k.created_at, k.updated_at, k.encrypted
+ ORDER BY k.name ASC",
+ )
+ .fetch_all(&self.pool)
+ .await?;
+
+ Ok(rows
+ .into_iter()
+ .map(
+ |(
+ id,
+ name,
+ public_key,
+ fingerprint,
+ created_at,
+ updated_at,
+ encrypted,
+ target_count,
+ )| SshKeyEntry {
+ id,
+ name,
+ public_key,
+ fingerprint,
+ created_at,
+ updated_at,
+ encrypted: encrypted != 0,
+ target_count,
+ },
+ )
+ .collect())
+ }
+
+ pub async fn create_ssh_key(
+ &self,
+ name: &str,
+ private_key: &str,
+ public_key: &str,
+ fingerprint: &str,
+ ) -> anyhow::Result {
+ let name = name.trim();
+ if name.is_empty() {
+ anyhow::bail!("ssh key name is required");
+ }
+
+ #[cfg(feature = "vault")]
+ let (store_private_key, encrypted) = {
+ if let Some(ref vault) = self.vault {
+ if vault.is_unsealed().await {
+ let aad = format!("ssh-key:{name}");
+ let enc = vault.encrypt_string(private_key, &aad).await?;
+ (enc, 1_i64)
+ } else {
+ // Managed SSH keys created while the vault is locked are
+ // stored transiently in plaintext and upgraded by the
+ // vault migration on the next successful unseal.
+ (private_key.to_owned(), 0_i64)
+ }
+ } else {
+ (private_key.to_owned(), 0_i64)
+ }
+ };
+ #[cfg(not(feature = "vault"))]
+ let (store_private_key, encrypted) = (private_key.to_owned(), 0_i64);
+
+ let result = sqlx::query(
+ "INSERT INTO ssh_keys (name, private_key, public_key, fingerprint, encrypted)
+ VALUES (?, ?, ?, ?, ?)",
+ )
+ .bind(name)
+ .bind(store_private_key)
+ .bind(public_key.trim())
+ .bind(fingerprint.trim())
+ .bind(encrypted)
+ .execute(&self.pool)
+ .await?;
+
+ Ok(result.last_insert_rowid())
+ }
+
+ pub async fn delete_ssh_key(&self, id: i64) -> anyhow::Result<()> {
+ let deleted = sqlx::query(
+ "DELETE FROM ssh_keys
+ WHERE id = ?
+ AND NOT EXISTS (SELECT 1 FROM ssh_targets WHERE key_id = ?)",
+ )
+ .bind(id)
+ .bind(id)
+ .execute(&self.pool)
+ .await?;
+
+ if deleted.rows_affected() == 0 {
+ let in_use: Option<(i64,)> =
+ sqlx::query_as("SELECT COUNT(1) FROM ssh_targets WHERE key_id = ?")
+ .bind(id)
+ .fetch_optional(&self.pool)
+ .await?;
+ if in_use.is_some_and(|(count,)| count > 0) {
+ anyhow::bail!("ssh key is still assigned to one or more targets");
+ }
+ }
+ Ok(())
+ }
+
+ pub async fn get_ssh_private_key(&self, key_id: i64) -> anyhow::Result