Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@
"settings_general_defaultAgent": "Default Agent",
"settings_general_defaultAgentLabel": "Default agent for new sessions",
"settings_general_defaultAgentDesc": "Which agent new chat sessions start with. You can still switch per session.",
"settings_general_workspace": "Workspace",
"settings_general_workspaceLabel": "Workspace folder",
"settings_general_workspaceDesc": "Where home-chat sessions run. Point this at a project folder to load its CLAUDE.md and .claude layer (hooks, skills, project-scoped plugins). Empty = your home directory.",
"settings_general_workspaceBrowse": "Browse\u2026",
"settings_remote_claudeOnly": "Remote execution runs the Claude CLI over SSH. Codex sessions always run locally — these hosts do not apply to Codex.",
"settings_cliConfig_claudeGroup": "Claude",
"settings_cliConfig_codexGroup": "Codex",
Expand Down
4 changes: 4 additions & 0 deletions messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@
"settings_general_defaultAgent": "默认 Agent",
"settings_general_defaultAgentLabel": "新会话的默认 agent",
"settings_general_defaultAgentDesc": "新建对话默认使用哪个 agent,仍可在每个会话单独切换。",
"settings_general_workspace": "\u5de5\u4f5c\u533a",
"settings_general_workspaceLabel": "\u5de5\u4f5c\u533a\u6587\u4ef6\u5939",
"settings_general_workspaceDesc": "\u4e3b\u9875\u5bf9\u8bdd\u7684\u8fd0\u884c\u76ee\u5f55\u3002\u6307\u5411\u67d0\u4e2a\u9879\u76ee\u6587\u4ef6\u5939\u53ef\u52a0\u8f7d\u5176 CLAUDE.md \u548c .claude \u5c42\uff08hooks\u3001skills\u3001\u9879\u76ee\u7ea7\u63d2\u4ef6\uff09\u3002\u7559\u7a7a = \u4e3b\u76ee\u5f55\u3002",
"settings_general_workspaceBrowse": "\u6d4f\u89c8\u2026",
"settings_remote_claudeOnly": "远程执行通过 SSH 运行 Claude CLI。Codex 会话始终在本地运行——这些主机对 Codex 无效。",
"settings_cliConfig_claudeGroup": "Claude",
"settings_cliConfig_codexGroup": "Codex",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/agent/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ mod tests {
web_server_bind: None,
web_server_allowed_origins: None,
web_server_tunnel_url: None,
claude_path: None,
updated_at: String::new(),
}
}
Expand Down
111 changes: 102 additions & 9 deletions src-tauri/src/commands/brains_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,45 @@ fn entry() -> Result<Entry, String> {
.map_err(|e| format!("keychain unavailable: {}", e))
}

/// The user-configured workspace folder (Settings → General → Workspace),
/// i.e. where home-chat sessions run. `None` when unset.
fn configured_workspace() -> Option<String> {
let wd = crate::storage::settings::get_user_settings().working_directory?;
let wd = wd.trim().to_string();
if wd.is_empty() {
None
} else {
Some(wd)
}
}
Comment on lines +31 to +39

/// True when `{dir}/.claude/settings.json` enables the brains plugin — the user
/// deliberately scoped brains to that project, so a user-global enable would
/// widen the plugin (hooks + MCP) to every folder on the machine.
fn project_enables_brains(dir: &str) -> bool {
let path = std::path::Path::new(dir)
.join(".claude")
.join("settings.json");
let Ok(raw) = std::fs::read_to_string(&path) else {
return false;
};
let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&raw) else {
return false;
};
cfg.get("enabledPlugins")
.and_then(|v| v.get(BRAINS_PLUGIN_KEY))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}

/// True when the workspace is set and enables the plugin at project scope —
/// home-chat sessions run there, so no user-global enable is needed.
fn workspace_scoped_enable() -> bool {
configured_workspace()
.map(|wd| project_enables_brains(&wd))
.unwrap_or(false)
}

/// Store the brains token in the OS keychain (source of truth).
#[tauri::command]
pub fn brains_token_save(token: String) -> Result<(), String> {
Expand Down Expand Up @@ -64,7 +103,9 @@ pub fn brains_token_delete() -> Result<(), String> {
}

/// Provision the brains plugin so its MCP can authenticate: enable it and write
/// its `options.{endpoint,token}` into `~/.claude/settings.json`.
/// its `options.{endpoint,token}` into `~/.claude/settings.json`. The user-global
/// enable is skipped when the configured workspace already enables the plugin at
/// project scope (the home chat runs there, so it still loads).
#[tauri::command]
pub fn brains_provision_plugin(token: String, endpoint: Option<String>) -> Result<(), String> {
let token = token.trim().to_string();
Expand All @@ -81,12 +122,21 @@ pub fn brains_provision_plugin(token: String, endpoint: Option<String>) -> Resul
.as_object_mut()
.ok_or("settings.json is not a JSON object")?;

// enabledPlugins[brains@brains] = true
obj.entry("enabledPlugins")
.or_insert_with(|| json!({}))
.as_object_mut()
.ok_or("enabledPlugins is not an object")?
.insert(BRAINS_PLUGIN_KEY.into(), json!(true));
// enabledPlugins[brains@brains] = true — but only when the configured
// workspace doesn't already enable the plugin at project scope. Users who
// sandbox brains to one folder keep that scoping; the token below is
// config only and activates nothing on its own.
if workspace_scoped_enable() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The user-global enabledPlugins skip is bypassed by a sibling entry point: ensureBrains in src/routes/+page.svelte first runs ensureBrainsPlugin (src/lib/brains-setup.ts), which unconditionally calls enablePlugin(BRAINS_PLUGIN_ID, "user")claude plugin enable --scope user — before brains_provision_plugin ever runs. On a fresh install, and whenever claude plugin list reports the project-scoped plugin as not enabled (it runs outside the workspace), sign-in still widens brains user-globally, defeating this guard. Apply the workspace_scoped_enable check in the ensureBrainsPlugin enable step too (or enable with project scope/cwd there).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Nit: the new tests cover project_enables_brains() (the pure JSON-parsing helper) well, but the actual scoping decision — brains_provision_plugin skipping the global enabledPlugins write when workspace_scoped_enable() is true, and brains_is_provisioned OR-ing it in — has no test exercising that wiring end-to-end (e.g. via a temp HOME/settings override). Not blocking given the isolated logic is tested, but this is the actual behavior change users depend on.

log::debug!(
"[brains_setup] workspace enables the plugin at project scope — skipping user-global enable"
);
} else {
obj.entry("enabledPlugins")
.or_insert_with(|| json!({}))
.as_object_mut()
.ok_or("enabledPlugins is not an object")?
.insert(BRAINS_PLUGIN_KEY.into(), json!(true));
}

// pluginConfigs[brains@brains].options = { endpoint, token }
let options = obj
Expand All @@ -111,15 +161,17 @@ pub fn brains_provision_plugin(token: String, endpoint: Option<String>) -> Resul
}

/// True when the brains plugin is enabled AND its MCP token is present in
/// settings.json — i.e. the plugin can actually authenticate.
/// settings.json — i.e. the plugin can actually authenticate. A project-scoped
/// enable in the configured workspace counts: home-chat sessions run there.
#[tauri::command]
pub fn brains_is_provisioned() -> Result<bool, String> {
let cfg = crate::storage::cli_config::load_cli_config();
let enabled = cfg
.get("enabledPlugins")
.and_then(|v| v.get(BRAINS_PLUGIN_KEY))
.and_then(|v| v.as_bool())
.unwrap_or(false);
.unwrap_or(false)
|| workspace_scoped_enable();
let has_token = cfg
.get("pluginConfigs")
.and_then(|v| v.get(BRAINS_PLUGIN_KEY))
Expand Down Expand Up @@ -154,3 +206,44 @@ pub fn brains_deprovision_plugin() -> Result<(), String> {
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

fn write_project_settings(dir: &std::path::Path, body: &str) {
let claude = dir.join(".claude");
std::fs::create_dir_all(&claude).unwrap();
std::fs::write(claude.join("settings.json"), body).unwrap();
}

#[test]
fn project_enable_detected() {
let tmp = tempfile::tempdir().unwrap();
write_project_settings(tmp.path(), r#"{"enabledPlugins": {"brains@brains": true}}"#);
assert!(project_enables_brains(tmp.path().to_str().unwrap()));
}

#[test]
fn project_enable_absent_or_false() {
let tmp = tempfile::tempdir().unwrap();
assert!(
!project_enables_brains(tmp.path().to_str().unwrap()),
"no .claude/settings.json"
);
write_project_settings(tmp.path(), r#"{"enabledPlugins": {}}"#);
assert!(!project_enables_brains(tmp.path().to_str().unwrap()));
write_project_settings(
tmp.path(),
r#"{"enabledPlugins": {"brains@brains": false}}"#,
);
assert!(!project_enables_brains(tmp.path().to_str().unwrap()));
}

#[test]
fn project_enable_malformed_json() {
let tmp = tempfile::tempdir().unwrap();
write_project_settings(tmp.path(), "not json {");
assert!(!project_enables_brains(tmp.path().to_str().unwrap()));
}
}
8 changes: 3 additions & 5 deletions src-tauri/src/commands/onboarding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,8 @@ pub async fn install_agent_cli(app: AppHandle, agent: String) -> Result<bool, St
}

let (program, args, label) = resolve_install_command(&agent).await.ok_or_else(|| {
"No installer available — please install curl, Homebrew, or Node.js (npm) first.".to_string()
"No installer available — please install curl, Homebrew, or Node.js (npm) first."
.to_string()
})?;

let _ = app.emit("setup-progress", format!("Installing via {}…", label));
Expand Down Expand Up @@ -354,10 +355,7 @@ pub async fn install_agent_cli(app: AppHandle, agent: String) -> Result<bool, St
.map_err(|e| format!("Installer error: {}", e))?;

if !status.success() {
return Err(format!(
"Installer exited with code {:?}",
status.code()
));
return Err(format!("Installer exited with code {:?}", status.code()));
}

// Verify the binary is now resolvable (native installers drop into ~/.local/bin
Expand Down
8 changes: 6 additions & 2 deletions src-tauri/src/commands/recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,8 +546,12 @@ fn wav_header(bytes: &[u8]) -> Option<(u32, usize)> {
let mut pos = 12;
while pos + 8 <= bytes.len() {
let id = &bytes[pos..pos + 4];
let sz = u32::from_le_bytes([bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]])
as usize;
let sz = u32::from_le_bytes([
bytes[pos + 4],
bytes[pos + 5],
bytes[pos + 6],
bytes[pos + 7],
]) as usize;
let body = pos + 8;
if id == b"fmt " && body + 16 <= bytes.len() {
byte_rate = u32::from_le_bytes([
Expand Down
29 changes: 29 additions & 0 deletions src/lib/utils/home-cwd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* brains-desktop — resolve the working directory for home-chat sessions.
*
* By default the home chat spawns in the OS home directory. Users who keep a
* dedicated workspace (a folder with its own CLAUDE.md / `.claude/` layer —
* hooks, skills, project-scoped plugin enables) can point the home chat there
* via Settings → General → Workspace, so those load exactly like a terminal
* session started in that folder.
*/

import * as api from "$lib/api";

/** The configured workspace folder (settings.working_directory), or null. */
export async function configuredWorkspace(): Promise<string | null> {
try {
const wd = (await api.getUserSettings()).working_directory?.trim();
return wd ? wd : null;
} catch {
return null;
}
}
Comment on lines +14 to +21

/** Cwd for home-chat sessions: the configured workspace, else the home dir. */
export async function homeChatCwd(): Promise<string> {
const wd = await configuredWorkspace();
if (wd) return wd;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 homeChatCwd returns the raw working_directory string with no existence check and no ~ expansion, and it goes straight to Command::current_dir in the spawn path (session.rs); the settings input is free text with placeholder ~, so a typed ~/foo, a relative path, or a since-deleted folder makes every home-chat spawn (warm spawn, first message, transcript summary) fail. Expand ~, verify the directory exists, and fall back to the home dir otherwise; the Rust configured_workspace/project_enables_brains pair needs the same expansion or the provisioning decision silently misreads a tilde path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium: homeChatCwd() returns the configured workspace string as-is with no existence check (and no tilde expansion — the settings input's ~ placeholder may lead a user to type a literal ~/… path, which won't resolve). If the folder was later deleted/renamed, or was mistyped, this becomes the cwd passed into api.startRun/startSession for every home-chat entry point (warm spawn, first message, transcript summary).

Downstream, warmSession() in +page.svelte swallows the resulting spawn failure in a bare catch { runId = ""; ...; actorLive = false; } with no user-visible message, and the retry on the next real send hits the same broken cwd — so a stale/invalid Workspace setting can silently break the entire home chat with no indication that the Workspace setting is the cause and no fallback to the home directory. Before this PR, home chat always used homeDir(), which is guaranteed to exist, so this failure mode didn't exist. Worth at least a lightweight check (does the dir exist / is it a dir) before adopting it as cwd, with a fallback to home + a surfaced warning.

const { homeDir } = await import("@tauri-apps/api/path");
return homeDir();
}
10 changes: 4 additions & 6 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
checkReadiness,
isReady,
} from "$lib/brains-setup";
import { homeChatCwd } from "$lib/utils/home-cwd";
import { composeV2, isV2Body } from "$lib/dashboard-engine/compose";
import BuilderInspector from "$lib/components/BuilderInspector.svelte";
import type { Blueprint, BApi } from "$lib/components/BuilderInspector.svelte";
Expand Down Expand Up @@ -1397,8 +1398,7 @@
const agent = agentFor(model);
if (!isApp || agent !== "claude") return; // only Claude runs live; skip in browser preview
warmPromise = (async () => {
const { homeDir } = await import("@tauri-apps/api/path");
const cwd = await homeDir();
const cwd = await homeChatCwd();
const run: any = await api.startRun(
"",
cwd,
Expand Down Expand Up @@ -1889,8 +1889,7 @@
/** One-shot: hand the transcript to the user's model and get back notes only. */
async function summarizeTranscript(transcript: string): Promise<string> {
if (agentFor(model) !== "claude") return "";
const { homeDir } = await import("@tauri-apps/api/path");
const cwd = await homeDir();
const cwd = await homeChatCwd();
const prompt = `Summarize this recorded call into concise notes: a 2–3 sentence summary, then **Key points**, **Decisions**, and **Action items** as bullet lists. Reply with ONLY the notes in markdown — do not call any tools.\n\nTRANSCRIPT:\n${transcript}`;
try {
const run: any = await api.startRun(
Expand Down Expand Up @@ -2691,8 +2690,7 @@
armWatchdog(); // fires only after true silence (reset by every incoming event)
try {
if (!runId) {
const { homeDir } = await import("@tauri-apps/api/path");
const cwd = await homeDir();
const cwd = await homeChatCwd();
const run: any = await api.startRun(
prompt,
cwd,
Expand Down
55 changes: 55 additions & 0 deletions src/routes/settings/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,27 @@
}
}

// ── Workspace folder (home-chat cwd) ──
let workspaceInput = $state("");
$effect(() => {
if (settings) workspaceInput = settings.working_directory ?? "";
});
async function saveWorkspace() {
const next = workspaceInput.trim();
if ((settings?.working_directory ?? "") === next) return;
await saveGeneralPatch({ working_directory: next });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changing or clearing working_directory after a project-scoped provisioning leaves brains enabled nowhere the home chat runs: provision skipped the user-global enable based on the old workspace, new home-chat sessions now spawn in ~ (or the new folder) without the plugin, and nothing re-checks readiness until the next app boot (bootRoute is the only caller of checkReadiness). Silent degradation for the rest of the session. Re-run the readiness/provisioning check when working_directory changes.

}
Comment on lines +1607 to +1611
async function browseWorkspace() {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
directory: true,
title: t("settings_general_workspaceLabel"),
});
if (!selected) return;
workspaceInput = selected as string;
await saveWorkspace();
}

// ── Web Server helpers ──

async function applyWebServerSettings() {
Expand Down Expand Up @@ -1790,6 +1811,40 @@
</div>
</Card>

<!-- Workspace Card (home-chat cwd) -->
<Card class="p-6 space-y-3">
<h2 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
{t("settings_general_workspace")}
</h2>
<div>
<p class="text-sm font-medium">{t("settings_general_workspaceLabel")}</p>
<p class="text-xs text-muted-foreground">
{t("settings_general_workspaceDesc")}
</p>
</div>
<div class="flex gap-2">
<input
type="text"
bind:value={workspaceInput}
onblur={saveWorkspace}
onkeydown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
placeholder="~"
spellcheck="false"
autocapitalize="off"
autocomplete="off"
class="w-full rounded-md border bg-transparent px-3 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button
class="rounded-md border px-3 py-1.5 text-xs whitespace-nowrap hover:bg-accent transition-all duration-150"
onclick={browseWorkspace}
>
{t("settings_general_workspaceBrowse")}
</button>
</div>
</Card>

<!-- Language Card -->
<Card class="p-6 space-y-4">
<h2 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
Expand Down