Fix loading siGit Code on Xcode custom ACP agent
Seto Elkahfi committed
Jul 27, 2026 at 18:45 UTC
9044eebddef34e8b17943a68c63e3d66060bc990
3 files changed
+176
-64
README.md
+21
@@ -61,6 +61,27 @@ Add this to `~/.config/zed/settings.json`:
61
62
Use the full absolute path. `~` does not expand here.
63
64
+## Xcode custom agent
65
+
66
+In Xcode, open **Settings > Intelligence > Agents**, add a custom agent, and set:
67
+
68
+- **Executable:** the absolute path to `sigit`
69
+- **Arguments:** `--acp`
70
+
71
+The explicit `--acp` mode is designed for Xcode: it loads the selected on-device
72
+model on the first prompt, so you do not need to send `/load` from the Xcode chat.
73
+
74
+To let siGit use Xcode's build, test, and project tools, enable **Allow external
75
+agents to use Xcode tools** in Xcode's Intelligence settings, keep the project
76
+open, and add this to `~/.config/sigit/mcp.toml`:
77
+
78
+```toml
79
+[[server]]
80
+name = "xcode"
81
+command = "xcrun"
82
+args = ["mcpbridge"]
83
+```
84
+
85
## VS Code
86
87
### With siGit Code Extension
src/main.rs
+133
-58
@@ -433,6 +433,9 @@ struct SiGitAgent {
433
model_load_error: Arc<std::sync::Mutex<Option<String>>>,
434
/// true when the startup model isn't cached yet
435
startup_needs_download: bool,
436
+ /// Xcode launches custom agents with `--acp`; that mode should be ready to
437
+ /// answer the first prompt without requiring an editor slash command.
438
+ auto_load_local_model: bool,
439
/// for progress UI
440
startup_model_name: String,
441
/// for download-progress polling
@@ -453,6 +456,7 @@ impl SiGitAgent {
456
startup_model_load_started: Arc<AtomicBool>,
457
model_load_error: Arc<std::sync::Mutex<Option<String>>>,
458
startup_needs_download: bool,
459
+ auto_load_local_model: bool,
460
) -> Self {
461
let startup_model_name = initial_model.display_name.clone();
462
let startup_model_id = initial_model.model_id.clone();
@@ -466,6 +470,7 @@ impl SiGitAgent {
470
startup_model_load_started,
471
model_load_error,
472
startup_needs_download,
473
+ auto_load_local_model,
474
startup_model_name,
475
startup_model_id,
476
turn_lock: Arc::new(tokio::sync::Mutex::new(())),
@@ -1390,21 +1395,25 @@ impl SiGitAgent {
1395
let backend = self.backend.lock().await.clone();
1396
1397
// Only on-device inference needs a local model in memory. Cloud tiers run
1393
- // over the network, so they never need a local model. We never load the
1394
- // on-device model implicitly: the user loads it explicitly with `/load`
1395
- // (or by picking one in `/models`). If a prompt arrives before that, guide
1396
- // them rather than blocking on a multi-minute download/load.
1398
+ // over the network, so they never need a local model. Xcode's explicit
1399
+ // `--acp` mode loads lazily on the first prompt because its custom-agent
1400
+ // UI has no reliable equivalent of siGit's `/load` command.
1401
if !backend.is_remote()
1402
&& self.engine.info().await.status == onde::inference::EngineStatus::Unloaded
1403
{
1400
- self.send_assistant_message(
1401
- cx,
1402
- session_id,
1403
- "No on-device model is loaded. Run `/load` to load the selected model, \
1404
- or `/models` to choose one.",
1405
- )
1406
- .ok();
1407
- return Ok(PromptResponse::new(StopReason::EndTurn));
1404
+ if self.auto_load_local_model {
1405
+ self.start_startup_model_load_if_needed();
1406
+ self.await_model_ready(cx, &session_id).await?;
1407
+ } else {
1408
+ self.send_assistant_message(
1409
+ cx,
1410
+ session_id,
1411
+ "No on-device model is loaded. Run `/load` to load the selected model, \
1412
+ or `/models` to choose one.",
1413
+ )
1414
+ .ok();
1415
+ return Ok(PromptResponse::new(StopReason::EndTurn));
1416
+ }
1417
}
1418
1419
// ── tool-calling loop ────────────────────────────────────────────
@@ -1422,6 +1431,7 @@ impl SiGitAgent {
1431
let mut assembled = String::new();
1432
let mut sent = String::new();
1433
let mut streamed_any = false;
1434
+ let mut repeated_tool_calls = std::collections::HashMap::<String, usize>::new();
1435
1436
let mut result = self
1437
.drain_turn(
@@ -1470,6 +1480,7 @@ impl SiGitAgent {
1480
}
1481
1482
let mut tool_results = Vec::new();
1483
+ let mut force_text = false;
1484
1485
for (call_index, tc) in result.tool_calls.iter().enumerate() {
1486
log::info!(
@@ -1478,52 +1489,79 @@ impl SiGitAgent {
1489
tc.arguments.chars().take(120).collect::<String>()
1490
);
1491
1492
+ let signature = format!("{}\n{}", tc.name, tc.arguments);
1493
+ let repeat_count = repeated_tool_calls
1494
+ .entry(signature)
1495
+ .and_modify(|count| *count += 1)
1496
+ .or_insert(1);
1497
+ let repeated = *repeat_count >= 3;
1498
+ if repeated {
1499
+ force_text = true;
1500
+ log::warn!(
1501
+ "prompt({}) stopping repeated tool call `{}` after {} attempts",
1502
+ session_id,
1503
+ tc.name,
1504
+ repeat_count
1505
+ );
1506
+ }
1507
+
1508
// Permission gate: read-only tools pass straight through; a
1509
// mutating tool consults policy and may ask the client.
1483
- let output = match permissions::decision_for(
1484
- &session_id.to_string(),
1485
- &tc.name,
1486
- &tc.arguments,
1487
- ) {
1488
- permissions::Decision::Allow => {
1489
- tools::execute_tool(&tc.name, &tc.arguments).await
1490
- }
1491
- permissions::Decision::Deny(reason) => {
1492
- log::info!(" ✗ {} denied by policy", tc.name);
1493
- reason
1494
- }
1495
- permissions::Decision::Ask => {
1496
- match self
1497
- .request_tool_permission(cx, &session_id, &tc.name, &tc.arguments)
1498
- .await
1499
- {
1500
- PermissionVerdict::Approved => {
1501
- tools::execute_tool(&tc.name, &tc.arguments).await
1502
- }
1503
- PermissionVerdict::Denied(reason) => {
1504
- log::info!(" ✗ {} denied by user", tc.name);
1505
- reason
1506
- }
1507
- PermissionVerdict::TurnCancelled => {
1508
- log::info!("prompt({}) cancelled at permission gate", session_id);
1509
- // The assistant message carrying these tool
1510
- // calls is already in the backend history;
1511
- // leaving any of them unanswered makes strict
1512
- // OpenAI-compatible endpoints reject every
1513
- // later request in the session. Close out this
1514
- // call and the ones this round never reached.
1515
- for pending in &result.tool_calls[call_index..] {
1516
- tool_results.push(BackendToolResult {
1517
- tool_call_id: pending.id.clone(),
1518
- content: format!(
1519
- "`{}` was not executed: the user cancelled the turn \
1520
- at the permission prompt.",
1521
- pending.name
1522
- ),
1523
- });
1510
+ let output = if repeated {
1511
+ format!(
1512
+ "The tool `{}` was not executed again because the model repeated \
1513
+ the same call three times. Continue without this tool.",
1514
+ tc.name
1515
+ )
1516
+ } else {
1517
+ match permissions::decision_for(
1518
+ &session_id.to_string(),
1519
+ &tc.name,
1520
+ &tc.arguments,
1521
+ ) {
1522
+ permissions::Decision::Allow => {
1523
+ tools::execute_tool(&tc.name, &tc.arguments).await
1524
+ }
1525
+ permissions::Decision::Deny(reason) => {
1526
+ log::info!(" ✗ {} denied by policy", tc.name);
1527
+ reason
1528
+ }
1529
+ permissions::Decision::Ask => {
1530
+ match self
1531
+ .request_tool_permission(cx, &session_id, &tc.name, &tc.arguments)
1532
+ .await
1533
+ {
1534
+ PermissionVerdict::Approved => {
1535
+ tools::execute_tool(&tc.name, &tc.arguments).await
1536
+ }
1537
+ PermissionVerdict::Denied(reason) => {
1538
+ log::info!(" ✗ {} denied by user", tc.name);
1539
+ reason
1540
+ }
1541
+ PermissionVerdict::TurnCancelled => {
1542
+ log::info!(
1543
+ "prompt({}) cancelled at permission gate",
1544
+ session_id
1545
+ );
1546
+ // The assistant message carrying these tool
1547
+ // calls is already in the backend history;
1548
+ // leaving any of them unanswered makes strict
1549
+ // OpenAI-compatible endpoints reject every
1550
+ // later request in the session. Close out this
1551
+ // call and the ones this round never reached.
1552
+ for pending in &result.tool_calls[call_index..] {
1553
+ tool_results.push(BackendToolResult {
1554
+ tool_call_id: pending.id.clone(),
1555
+ content: format!(
1556
+ "`{}` was not executed: the user cancelled the turn \
1557
+ at the permission prompt.",
1558
+ pending.name
1559
+ ),
1560
+ });
1561
+ }
1562
+ backend.record_cancelled_tool_results(tool_results).await;
1563
+ return Ok(PromptResponse::new(StopReason::Cancelled));
1564
}
1525
- backend.record_cancelled_tool_results(tool_results).await;
1526
- return Ok(PromptResponse::new(StopReason::Cancelled));
1565
}
1566
}
1567
}
@@ -1537,7 +1575,7 @@ impl SiGitAgent {
1575
});
1576
}
1577
1540
- let next_tools = if round < MAX_TOOL_ROUNDS {
1578
+ let next_tools = if round < MAX_TOOL_ROUNDS && !force_text {
1579
Some(tools.as_slice())
1580
} else {
1581
None // last round: force text
@@ -3064,7 +3102,17 @@ fn default_local_model_config() -> GgufModelConfig {
3102
.unwrap_or_else(GgufModelConfig::qwen25_3b)
3103
}
3104
3067
-async fn run_acp_server() -> anyhow::Result<()> {
3105
+fn parse_explicit_acp(args: &[String]) -> Result<bool, String> {
3106
+ if !args.iter().any(|arg| arg == "--acp") {
3107
+ return Ok(false);
3108
+ }
3109
+ if args.iter().any(|arg| arg != "--acp") {
3110
+ return Err("--acp does not accept additional arguments".to_string());
3111
+ }
3112
+ Ok(true)
3113
+}
3114
+
3115
+async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
3116
log::info!("ACP mode — starting agent server");
3117
3118
let config = default_local_model_config();
@@ -3102,6 +3150,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
3150
startup_model_load_started,
3151
model_load_error,
3152
needs_download,
3153
+ auto_load_local_model,
3154
));
3155
3156
// Honor the explicit provider override (OPENAI_BASE_URL/OPENAI_API_KEY or
@@ -3294,6 +3343,25 @@ async fn main() -> anyhow::Result<()> {
3343
// is dispatched here, before the TTY/ACP split, like the account
3344
// subcommands above.
3345
let cli_args: Vec<String> = std::env::args().skip(1).collect();
3346
+ // Xcode's custom-agent UI launches ACP agents with an explicit `--acp`
3347
+ // argument. Honor it even when Xcode gives the child a terminal-like stdin.
3348
+ match parse_explicit_acp(&cli_args) {
3349
+ Err(error) => {
3350
+ eprintln!("sigit: {error}");
3351
+ std::process::exit(2);
3352
+ }
3353
+ Ok(true) => {
3354
+ init_logging(false);
3355
+ setup::setup_shared_model_cache();
3356
+ mcp::init().await;
3357
+ log::info!(
3358
+ "siGit v{} starting (explicit ACP mode)",
3359
+ env!("CARGO_PKG_VERSION")
3360
+ );
3361
+ return run_acp_server(true).await;
3362
+ }
3363
+ Ok(false) => {}
3364
+ }
3365
match headless::parse_args(&cli_args) {
3366
Ok(None) => {}
3367
Ok(Some(config)) => {
@@ -3350,7 +3418,7 @@ async fn main() -> anyhow::Result<()> {
3418
// Best-effort MCP discovery (incl. the official server) before serving.
3419
mcp::init().await;
3420
log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION"));
3353
- run_acp_server().await
3421
+ run_acp_server(false).await
3422
}
3423
}
3424
@@ -3358,6 +3426,13 @@ async fn main() -> anyhow::Result<()> {
3426
mod tests {
3427
use super::*;
3428
3429
+ #[test]
3430
+ fn explicit_acp_flag_is_accepted_without_other_arguments() {
3431
+ assert_eq!(parse_explicit_acp(&["--acp".to_string()]), Ok(true));
3432
+ assert_eq!(parse_explicit_acp(&[]), Ok(false));
3433
+ assert!(parse_explicit_acp(&["--acp".to_string(), "--quiet".to_string()]).is_err());
3434
+ }
3435
+
3436
#[test]
3437
fn system_prompt_advertises_the_commit_co_author_trailer() {
3438
// The prompt instructs the model with the exact trailer that
src/mcp.rs
+22
-6
@@ -127,9 +127,12 @@ const PROTOCOL_VERSION: &str = "2025-06-18";
127
/// sum.
128
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(8);
129
130
-/// Overall request timeout for an individual `tools/call`. Generous, since an
131
-/// MCP tool may do real work server-side.
130
+/// Overall request timeout for an individual `tools/call`. Generous for build
131
+/// and test tools, while the Xcode bridge gets a shorter bound below because
132
+/// it otherwise leaves an ACP prompt looking permanently busy when Xcode
133
+/// cannot service the request.
134
const CALL_TIMEOUT: Duration = Duration::from_secs(120);
135
+const XCODE_CALL_TIMEOUT: Duration = Duration::from_secs(30);
136
137
/// Cap on the characters returned from a single tool call, so a chatty server
138
/// can't blow up the model's context. Matches the spirit of the file-read cap.
@@ -818,7 +821,12 @@ impl Mcp {
821
let result = match &server.transport {
822
None => return Err(format!("server '{}' is not connected", server.name)),
823
Some(Transport::Stdio(stdio)) => {
821
- stdio.request("tools/call", params, CALL_TIMEOUT).await?
824
+ let timeout = if server.name == "xcode" {
825
+ XCODE_CALL_TIMEOUT
826
+ } else {
827
+ CALL_TIMEOUT
828
+ };
829
+ stdio.request("tools/call", params, timeout).await?
830
}
831
Some(Transport::Http(http_conn)) => {
832
let body = json!({
@@ -827,14 +835,19 @@ impl Mcp {
835
"method": "tools/call",
836
"params": params
837
});
830
- match post_rpc(&self.http, &server.name, http_conn, &body, CALL_TIMEOUT).await {
838
+ let timeout = if server.name == "xcode" {
839
+ XCODE_CALL_TIMEOUT
840
+ } else {
841
+ CALL_TIMEOUT
842
+ };
843
+ match post_rpc(&self.http, &server.name, http_conn, &body, timeout).await {
844
Ok(result) => result,
845
Err(error) if error.contains("returned 404") => {
846
// Session expired — drop it, re-handshake, and retry once.
847
*http_conn.session_id.lock().await = None;
848
initialize(&self.http, server).await?;
849
notify_initialized(&self.http, server).await?;
837
- post_rpc(&self.http, &server.name, http_conn, &body, CALL_TIMEOUT).await?
850
+ post_rpc(&self.http, &server.name, http_conn, &body, timeout).await?
851
}
852
Err(error) => return Err(error),
853
}
@@ -1027,7 +1040,10 @@ async fn stdio_reader(mut stdout: BufReader<ChildStdout>, shared: Arc<StdioShare
1040
);
1041
continue;
1042
}
1030
- let Some(id) = message.get("id").and_then(Value::as_i64) else {
1043
+ let Some(id) = message
1044
+ .get("id")
1045
+ .and_then(|value| value.as_i64().or_else(|| value.as_str()?.parse().ok()))
1046
+ else {
1047
log::warn!(
1048
"mcp: '{}' sent a response without a usable id; ignoring",
1049
shared.name