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) =