diff --git a/apps/mark/src-tauri/src/session_commands.rs b/apps/mark/src-tauri/src/session_commands.rs index af6c9c99..d70020be 100644 --- a/apps/mark/src-tauri/src/session_commands.rs +++ b/apps/mark/src-tauri/src/session_commands.rs @@ -319,8 +319,9 @@ pub struct ProjectSessionResponse { /// Start a project-level session. /// /// Project sessions operate at the project level rather than a specific branch. -/// The agent receives project context (all repos, existing project notes), -/// and an MCP server with tools to start repo subagent sessions and add repos. +/// The agent receives project context (all repos, existing project notes). +/// Sessions receive an MCP server with tools to start repo subagent sessions +/// and add repos. /// Always creates a ProjectNote stub that is populated when the session completes. #[tauri::command(rename_all = "camelCase")] #[allow(clippy::too_many_arguments)] @@ -342,26 +343,57 @@ pub async fn start_project_session( .ok_or_else(|| format!("Project not found: {project_id}"))?; // Build project context for the prompt - let project_context = build_project_session_context(&store, &project); + let project_context = build_project_session_context(&store, &project, None); // Build the full prompt - let action_instructions = "The user is requesting work at the project level. Investigate and \ + let action_instructions = if project.location == store::ProjectLocation::Remote { + "The user is requesting work at the project level. Investigate and \ + fulfill the request below, then produce a project note summarizing what you found and any \ + actions taken.\n\n\ + This top-level project session runs locally and acts as a coordinator. \ + For repository-specific execution, use MCP subagent tools.\n\n\ + This is a remote-workspace project. Use the project MCP tools to orchestrate work:\n\n\ + - start_repo_session: Use this to make changes or run tasks in one of the project's \ + repositories. Use `expected_outcome=\"note_in_repo\"` for repo notes and \ + `expected_outcome=\"commit\"` for code changes/commits. For remote branches this subagent \ + runs on the remote workspace, where file access, notes, and commits must happen.\n\n\ + - add_project_repo: Use this when the task requires a repository that isn't yet in the \ + project. Pass the GitHub repo slug to add it.\n\n\ + IMPORTANT: `add_project_repo` and `start_repo_session` are MCP tools, not shell commands. \ + Do not run `which`/`type` for these names and do not ask the user to add repos manually \ + unless the MCP tool call itself returns an error. If the tool call fails, report the exact \ + error and the next action needed.\n\n\ + Keep this project session focused on coordination and synthesis. Do not perform \ + repository edits directly here; use `start_repo_session` for implementation work.\n\n\ + To discover repositories that might be relevant, use `gh` to explore repos in the user's \ + GitHub organizations. Only add repos from organizations the user already belongs to.\n\n\ + To return the note, include a horizontal rule (---) followed by the note content. \ + Begin the note with a markdown H1 heading as the title. \n\n\ + " + } else { + "The user is requesting work at the project level. Investigate and \ fulfill the request below, then produce a project note summarizing what you found and any \ actions taken.\n\n\ You have access to the following tools:\n\n\ - start_repo_session: Use this to make changes or run tasks within one of the project's \ repositories. Pass the repo slug (e.g. \"org/repo\") and clear instructions for what to \ do there. This tool starts a subagent session and waits for it to complete before \ - returning the outcome. Do not ask for both a note and a commit in a single start_repo_session \ + returning the outcome. Use `expected_outcome=\"note_in_repo\"` for repo notes and \ + `expected_outcome=\"commit\"` for code changes/commits. Do not ask for both a note and a commit in a single start_repo_session \ request — choose one outcome per call. All reasoning specific to a repo should be done within \ a repo session rather in this project wide context.\n\n\ - add_project_repo: Use this when the task requires a repository that isn't yet in the \ project. Pass the GitHub repo slug to add it.\n\n\ + IMPORTANT: `add_project_repo` and `start_repo_session` are MCP tools, not shell commands. \ + Do not run `which`/`type` for these names and do not ask the user to add repos manually \ + unless the MCP tool call itself returns an error. If the tool call fails, report the exact \ + error and the next action needed.\n\n\ To discover repositories that might be relevant, use `gh` to explore repos in the user's \ GitHub organizations. Only add repos from organizations the user already belongs to.\n\n\ To return the note, include a horizontal rule (---) followed by the note content. \ Begin the note with a markdown H1 heading as the title. \n\n\ - "; + " + }; let full_prompt = format!( "\n{action_instructions}\n\nProject information:\n{project_context}\n\n\n{prompt}" @@ -856,7 +888,11 @@ pub(crate) fn build_project_context( /// /// Includes: project name, all attached repos (with reasons and per-repo /// branch timelines), and existing project notes. -fn build_project_session_context(store: &Arc, project: &store::Project) -> String { +fn build_project_session_context( + store: &Arc, + project: &store::Project, + workspace_name: Option<&str>, +) -> String { let project_name = project.name.trim(); let project_name = if project_name.is_empty() { "Unnamed Project" @@ -912,7 +948,8 @@ fn build_project_session_context(store: &Arc, project: &store::Project) - lines.push(String::new()); lines.push(format!("### Branch: {}", branch.branch_name)); - let timeline = build_branch_timeline_summary(store, branch); + let timeline = + build_branch_timeline_summary(store, branch, branch.workspace_name.as_deref()); if timeline.is_empty() { lines.push("No activity on this branch yet.".to_string()); } else { @@ -933,7 +970,8 @@ fn build_project_session_context(store: &Arc, project: &store::Project) - lines.push(String::new()); lines.push(format!("### Branch: {}", branch.branch_name)); - let timeline = build_branch_timeline_summary(store, branch); + let timeline = + build_branch_timeline_summary(store, branch, branch.workspace_name.as_deref()); if timeline.is_empty() { lines.push("No activity on this branch yet.".to_string()); } else { @@ -949,18 +987,12 @@ fn build_project_session_context(store: &Arc, project: &store::Project) - lines.push(String::new()); lines.push("## Existing Project Notes".to_string()); for note in &non_empty_notes { - let note_path = std::env::temp_dir().join(format!("mark-note-{}.md", note.id)); - let formatted = match std::fs::write(¬e_path, ¬e.content) { - Ok(()) => format!( - "### Project Note: {}\n\nSee: `{}`", - note.title, - note_path.display() - ), - Err(e) => { - log::warn!("Failed to write project note to temp file, inlining: {e}"); - format!("### Project Note: {}\n\n{}", note.title, note.content) - } - }; + let formatted = format_project_note_for_context( + ¬e.id, + ¬e.title, + ¬e.content, + workspace_name, + ); lines.push(formatted); } } @@ -974,7 +1006,11 @@ fn build_project_session_context(store: &Arc, project: &store::Project) - /// Includes commit log (when a local worktree is available), notes, and /// reviews — but omits project-level notes (those are rendered separately /// at the project level to avoid duplication). -fn build_branch_timeline_summary(store: &Arc, branch: &store::Branch) -> String { +fn build_branch_timeline_summary( + store: &Arc, + branch: &store::Branch, + workspace_name: Option<&str>, +) -> String { let mut timeline: Vec = Vec::new(); let mut commit_error = None; @@ -998,8 +1034,9 @@ fn build_branch_timeline_summary(store: &Arc, branch: &store::Branch) -> } } - // Notes written to local temp files — project sessions run locally - timeline.extend(note_timeline_entries(store, &branch.id, None)); + // Notes are written to temp files in the matching execution environment: + // remote workspace when available, otherwise local temp files. + timeline.extend(note_timeline_entries(store, &branch.id, workspace_name)); timeline.extend(review_timeline_entries(store, &branch.id)); if timeline.is_empty() { diff --git a/apps/mark/src/lib/features/agents/AgentSelector.svelte b/apps/mark/src/lib/features/agents/AgentSelector.svelte index 29b41e5a..2e745321 100644 --- a/apps/mark/src/lib/features/agents/AgentSelector.svelte +++ b/apps/mark/src/lib/features/agents/AgentSelector.svelte @@ -14,20 +14,28 @@ import { onMount, onDestroy } from 'svelte'; import { ChevronDown, Check, Bot } from 'lucide-svelte'; import { agentState, REMOTE_AGENTS } from './agent.svelte'; - import { setAiAgent, getPreferredAgent } from '../settings/preferences.svelte'; + import { + setAiAgent, + getPreferredAgent, + setProjectAiAgent, + getPreferredAgentForProject, + } from '../settings/preferences.svelte'; interface Props { disabled?: boolean; remote?: boolean; + projectId?: string | null; } - let { disabled = false, remote = false }: Props = $props(); + let { disabled = false, remote = false, projectId = null }: Props = $props(); let showDropdown = $state(false); let agents = $derived(remote ? REMOTE_AGENTS : agentState.providers); - let preferredId = $derived(getPreferredAgent(agents)); + let preferredId = $derived( + projectId ? getPreferredAgentForProject(projectId, agents) : getPreferredAgent(agents) + ); let currentLabel = $derived(agents.find((p) => p.id === preferredId)?.label ?? 'Agent'); @@ -47,7 +55,11 @@ } function select(id: string) { - setAiAgent(id); + if (projectId) { + setProjectAiAgent(projectId, id); + } else { + setAiAgent(id); + } showDropdown = false; } diff --git a/apps/mark/src/lib/features/projects/ProjectSection.svelte b/apps/mark/src/lib/features/projects/ProjectSection.svelte index 92d66cb1..c068b175 100644 --- a/apps/mark/src/lib/features/projects/ProjectSection.svelte +++ b/apps/mark/src/lib/features/projects/ProjectSection.svelte @@ -31,8 +31,8 @@ import NoteModal from '../notes/NoteModal.svelte'; import SessionModal from '../sessions/SessionModal.svelte'; import AgentSelector from '../agents/AgentSelector.svelte'; - import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte'; - import { getPreferredAgent } from '../settings/preferences.svelte'; + import { agentState } from '../agents/agent.svelte'; + import { getPreferredAgentForProject } from '../settings/preferences.svelte'; interface Props { project: Project; @@ -160,11 +160,14 @@ // ── Project session input ────────────────────────────────────────────── let promptText = $state(''); let promptTextarea = $state(null); - let availableAgents = $derived( - project.location === 'remote' ? REMOTE_AGENTS : agentState.providers + let availableAgents = $derived(agentState.providers); + let preferredProvider = $derived( + getPreferredAgentForProject(project.id, availableAgents) ?? undefined ); - let preferredProvider = $derived(getPreferredAgent(availableAgents) ?? undefined); let canSubmitPrompt = $derived(!!promptText.trim() && !!preferredProvider); + let sendButtonTitle = $derived( + preferredProvider ? 'Start project session' : 'No AI agent available' + ); /** Session IDs for running project sessions (all produce notes). */ let activeSessionIds = $state>(new Set()); @@ -382,12 +385,12 @@ rows={1} >
- + diff --git a/apps/mark/src/lib/features/settings/preferences.svelte.ts b/apps/mark/src/lib/features/settings/preferences.svelte.ts index 00d68d8e..5a727937 100644 --- a/apps/mark/src/lib/features/settings/preferences.svelte.ts +++ b/apps/mark/src/lib/features/settings/preferences.svelte.ts @@ -34,6 +34,7 @@ const SIZE_DEFAULT = 13; const SIZE_STORE_KEY = 'size-base'; const SYNTAX_THEME_STORE_KEY = 'syntax-theme'; const RECENT_AGENTS_STORE_KEY = 'recent-agents'; +const PROJECT_AGENTS_STORE_KEY = 'project-agents'; /** Maximum number of recent agents to remember. */ const RECENT_AGENTS_MAX = 10; @@ -65,6 +66,11 @@ export const preferences = $state({ * Used to pick the best available agent for a given context (local vs remote). */ recentAgents: [] as string[], + /** + * Per-project preferred AI agent (projectId -> agentId). + * Used by project-level chat so changing one project does not affect others. + */ + projectAgents: {} as Record, /** Whether all preferences have been loaded from storage */ loaded: false, }); @@ -132,6 +138,12 @@ export async function initPreferences(): Promise { } } + // Load per-project agent preferences + const savedProjectAgents = await getStoreValue>(PROJECT_AGENTS_STORE_KEY); + if (savedProjectAgents && typeof savedProjectAgents === 'object') { + preferences.projectAgents = savedProjectAgents; + } + preferences.loaded = true; } @@ -223,3 +235,26 @@ export function getPreferredAgent(available: { id: string }[]): string | null { } return available[0]?.id ?? null; } + +/** + * Set preferred agent for a specific project. + */ +export function setProjectAiAgent(projectId: string, agentId: string): void { + if (!projectId) return; + preferences.projectAgents = { ...preferences.projectAgents, [projectId]: agentId }; + setStoreValue(PROJECT_AGENTS_STORE_KEY, preferences.projectAgents); +} + +/** + * Return the preferred agent for a project, falling back to global preference. + */ +export function getPreferredAgentForProject( + projectId: string, + available: { id: string }[] +): string | null { + const scoped = preferences.projectAgents[projectId]; + if (scoped && available.some((a) => a.id === scoped)) { + return scoped; + } + return getPreferredAgent(available); +} diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 33f4a9a3..736cc3e5 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use agent_client_protocol::{ Agent, ClientSideConnection, ContentBlock as AcpContentBlock, Implementation, @@ -109,6 +109,7 @@ pub struct AcpDriver { } const REMOTE_ACP_MAX_PENDING_LINE_BYTES: usize = 256 * 1024; +const ACP_SETUP_TIMEOUT: Duration = Duration::from_secs(90); impl AcpDriver { /// Create a driver for the given provider ID (e.g. "goose", "claude"). @@ -406,6 +407,16 @@ fn consume_remote_acp_line(pending: &mut String, raw_line: &str) -> RemoteLineOu } } +fn remote_acp_segments(decoded_line: &str) -> impl Iterator { + // `sq blox acp` can emit JSON Text Sequences where records are delimited by + // U+001E (record separator). Keep line-based handling for normal JSON-RPC + // output, but split RS-delimited frames so concatenated messages are not + // treated as malformed JSON. + decoded_line + .split('\u{1e}') + .filter(|segment| !segment.trim().is_empty()) +} + async fn normalize_remote_acp_stdout( stdout: R, mut writer: tokio::io::DuplexStream, @@ -426,15 +437,17 @@ async fn normalize_remote_acp_stdout( log::warn!("Dropped invalid UTF-8 bytes from remote ACP stdout"); } - match consume_remote_acp_line(&mut pending, &decoded_line) { - RemoteLineOutcome::Emit(line) => { - writer.write_all(line.as_bytes()).await?; - writer.write_all(b"\n").await?; - } - RemoteLineOutcome::Pending => {} - RemoteLineOutcome::Dropped => { - if !decoded_line.trim().is_empty() { - log::warn!("Dropped malformed ACP proxy output line"); + for segment in remote_acp_segments(&decoded_line) { + match consume_remote_acp_line(&mut pending, segment) { + RemoteLineOutcome::Emit(line) => { + writer.write_all(line.as_bytes()).await?; + writer.write_all(b"\n").await?; + } + RemoteLineOutcome::Pending => {} + RemoteLineOutcome::Dropped => { + if !segment.trim().is_empty() { + log::warn!("Dropped malformed ACP proxy output line"); + } } } } @@ -541,15 +554,24 @@ async fn run_acp_protocol( handler: &Arc, mcp_servers: &[McpServer], ) -> Result<(), String> { - let agent_session_id = setup_acp_session( - connection, - working_dir, - store, - our_session_id, - acp_session_id, - mcp_servers, + let agent_session_id = tokio::time::timeout( + ACP_SETUP_TIMEOUT, + setup_acp_session( + connection, + working_dir, + store, + our_session_id, + acp_session_id, + mcp_servers, + ), ) - .await?; + .await + .map_err(|_| { + format!( + "Timed out waiting for ACP protocol startup after {}s", + ACP_SETUP_TIMEOUT.as_secs() + ) + })??; handler.set_live(); @@ -582,6 +604,26 @@ async fn setup_acp_session( .await .map_err(|e| format!("ACP init failed: {e:?}"))?; + if !mcp_servers.is_empty() { + let mcp_caps = &init_response.agent_capabilities.mcp_capabilities; + let requires_http = mcp_servers + .iter() + .any(|server| matches!(server, McpServer::Http(_))); + let requires_sse = mcp_servers + .iter() + .any(|server| matches!(server, McpServer::Sse(_))); + + if (requires_http && !mcp_caps.http) || (requires_sse && !mcp_caps.sse) { + return Err(format!( + "Agent does not support required MCP transports (required: http={}, sse={}; agent: http={}, sse={}). Select a provider with MCP support for project tools.", + requires_http, + requires_sse, + mcp_caps.http, + mcp_caps.sse + )); + } + } + match acp_session_id { Some(existing_id) => { if !init_response.agent_capabilities.load_session { @@ -713,8 +755,8 @@ impl MessageWriter for BasicMessageWriter { #[cfg(test)] mod tests { use super::{ - consume_remote_acp_line, decode_remote_acp_line, resolve_spawn_working_dir, - sanitize_remote_acp_chunk, RemoteLineOutcome, + consume_remote_acp_line, decode_remote_acp_line, remote_acp_segments, + resolve_spawn_working_dir, sanitize_remote_acp_chunk, RemoteLineOutcome, }; use std::time::{SystemTime, UNIX_EPOCH}; @@ -761,6 +803,28 @@ mod tests { ); } + #[test] + fn splits_record_separator_delimited_messages_in_one_stdout_line() { + let mut pending = String::new(); + let line = "\u{1e}{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null}\u{1e}{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":null}\n"; + + let outcomes: Vec = remote_acp_segments(line) + .map(|segment| consume_remote_acp_line(&mut pending, segment)) + .collect(); + + assert_eq!( + outcomes, + vec![ + RemoteLineOutcome::Emit( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null}".to_string() + ), + RemoteLineOutcome::Emit( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":null}".to_string() + ), + ] + ); + } + #[test] fn remote_utf8_decoder_strips_invalid_bytes() { let (decoded, had_invalid_utf8) =