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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,7 @@ backup taken before the upgrade.
| VS Code | Yes | User mode (Copilot Chat) | MCP and skills | — |
| Grok Build | Yes | System mode | MCP and skills | — |
| GitHub Copilot CLI | Yes | User mode | — | — |
| Pi | Yes | User mode | MCP and skills | — |

> **Don't see your tool?** We're actively expanding this list and would love
> your help. [Open an integration request](https://github.com/agentdesktop-dev/agentdesktop/issues/new)
Expand Down
88 changes: 88 additions & 0 deletions crates/agent/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ struct ResolvedDaemonArgs {
vscode_chat_models: Option<PathBuf>,
/// VS Code's user `settings.json`; `None` in system mode (user-only program).
vscode_settings: Option<PathBuf>,
pi: ResolvedPiStartupConfig,
/// `daemon.reconcileInterval`; `None` means no periodic re-apply.
reconcile_interval: Option<Duration>,
once: bool,
Expand All @@ -108,6 +109,11 @@ struct ResolvedOpenCodeStartupConfig {
plugin: PathBuf,
}

struct ResolvedPiStartupConfig {
models: PathBuf,
settings: PathBuf,
}

impl DaemonArgs {
fn resolve(
self,
Expand Down Expand Up @@ -211,6 +217,16 @@ impl DaemonArgs {
},
vscode_chat_models: None,
vscode_settings: None,
pi: ResolvedPiStartupConfig {
models: startup
.pi
.models
.unwrap_or_else(reconcile::default_pi_models_path),
settings: startup
.pi
.settings
.unwrap_or_else(reconcile::default_pi_settings_path),
},
reconcile_interval: startup.reconcile_interval,
once: self.once || self.dry_run,
dry_run: self.dry_run,
Expand Down Expand Up @@ -298,6 +314,14 @@ impl DaemonArgs {
.settings
.unwrap_or_else(|| reconcile::default_vscode_settings_path(&home)),
),
pi: ResolvedPiStartupConfig {
models: startup.pi.models.unwrap_or_else(|| {
crate::provider::pi::user_pi_agent_dir(&home).join("models.json")
}),
settings: startup.pi.settings.unwrap_or_else(|| {
crate::provider::pi::user_pi_agent_dir(&home).join("settings.json")
}),
},
reconcile_interval: startup.reconcile_interval,
once: self.once || self.dry_run,
dry_run: self.dry_run,
Expand Down Expand Up @@ -423,6 +447,8 @@ where
args.copilot_providers.clone(),
args.vscode_chat_models.clone(),
args.vscode_settings.clone(),
args.pi.models.clone(),
args.pi.settings.clone(),
agentdesktop_client_executable()?,
socket.clone(),
)
Expand Down Expand Up @@ -1009,6 +1035,11 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow
.vscode
.as_ref()
.is_some_and(|program| program.use_llm_gateway),
config
.programs
.pi
.as_ref()
.is_some_and(|program| program.use_llm_gateway),
]
.into_iter()
.any(|used| used);
Expand Down Expand Up @@ -1262,6 +1293,37 @@ mod tests {
use agentdesktop_core::model::{Agent, Discovery, LlmProxyInfo};
use tokio::sync::{mpsc, watch};

#[test]
fn pi_startup_paths_override_defaults_in_both_scopes() {
for user in [false, true] {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
format!(
"daemon:\n user: {user}\n pi:\n models: custom/pi-models.json\n settings: custom/pi-settings.json\n"
),
)
.unwrap();
let config = agentdesktop_core::config::load_daemon(file.path()).unwrap();
let args = super::DaemonArgs {
user: false,
once: false,
dry_run: false,
config: None,
};
let resolved = args
.resolve(
config.daemon.unwrap(),
file.path().to_path_buf(),
agentdesktop_core::DEFAULT_SOCKET_PATH.into(),
)
.unwrap();
assert_eq!(resolved.user, user);
assert_eq!(resolved.pi.models, Path::new("custom/pi-models.json"));
assert_eq!(resolved.pi.settings, Path::new("custom/pi-settings.json"));
}
}

fn discovery(kinds: &[&str]) -> Discovery {
Discovery {
agents: kinds
Expand Down Expand Up @@ -1717,6 +1779,32 @@ programs:
);
}

#[test]
fn one_shot_rejects_authenticated_pi_gateway() {
for authentication in [
" type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\n",
" type: controllerJwt\n audience: agentgateway\n allowedClientIds: [pi]\n",
] {
let config = parse_daemon(&format!(
"llmGateway:\n url: https://gateway.example.com\n authentication:\n{authentication}programs:\n pi:\n model: selected\n"
)).unwrap();
let error =
validate_one_shot(&config).expect_err("Pi credentials require a running daemon");
assert!(error.to_string().contains("credential helpers"));
}
}

#[test]
fn one_shot_accepts_pi_without_runtime_credentials() {
for yaml in [
"programs:\n pi: {}\n",
"llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n",
"llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\nprograms:\n pi:\n useLlmGateway: false\n",
] {
validate_one_shot(&parse_daemon(yaml).unwrap()).unwrap();
}
}

#[test]
fn one_shot_accepts_static_settings_and_rejects_runtime_services() {
let static_config = parse_daemon(
Expand Down
64 changes: 49 additions & 15 deletions crates/agent/src/provider/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Provider support

Features currently implemented by Agentdesktop.
Features currently implemented by agentdesktop.

VS Code managed configuration (Copilot Chat custom models in the user's
`chatLanguageModels.json`, or the Copilot Chat API override in the user's
Expand All @@ -14,24 +14,58 @@ Grok Build managed configuration requires system mode. Linux and macOS use
Grok's own configuration sync can delete or replace the user-level managed
file.

Pi stores configuration in `~/.pi/agent` (or `PI_CODING_AGENT_DIR`, with `~`
expanded to the user's home). Management requires `--user`; system mode
rejects `programs.pi` because Pi does not read `/etc/pi/agent`. Pi cannot be
combined with Claude Desktop or Grok Build, because those programs require
system mode. Authenticated
gateway configuration also requires a running daemon and rejects `--once`.
User mode merges `providers.agentdesktop` into `models.json` and sets the
default model in `settings.json`. Model map keys determine catalog IDs, and
gateway URLs override per-model endpoints, using each model's API dialect.
The gateway owns the entire `providers.agentdesktop.models` catalog while
enabled; stale entries are replaced, and the prior catalog is restored on
cleanup. Other providers and user additions are preserved. On Unix,
`models.json` is written with owner-only permissions (`0600`), and
reconciliation repairs a looser mode on the managed file; cleanup keeps the
file's current mode. Dry runs report the `models.json` action and path
without its contents, which may include the user's own API keys.
Commented `models.json` files are accepted and rewritten as standard JSON,
preserving user configuration values. `settings.json` requires standard JSON.
Discovery recognizes npm's Windows command shims as well as symlinked launchers,
verifying the Pi package manifest without executing the launcher.
MCP inventory comes from `pi-mcp-adapter` config files
(`~/.pi/agent/mcp.json`, the working directory's `.pi/mcp.json` and
`.mcp.json`, and shared `~/.config/mcp/mcp.json` / `~/.agents/mcp.json` /
`~/.agents/mcp/mcp.json`). Ancestor project files are included only when a
user-global config sets `settings.ancestorConfigRoots` to an existing
directory under the home that contains the working directory. Both `.agents`
paths are supported by the [adapter's configuration loader](https://github.com/nicobailon/pi-mcp-adapter/blob/33bdc38d8dd3802f2b51ba1fe37e30ed7ae6a29a/config.ts#L15-L19).
`PI_MCP_CONFIG_MODE=exclusive` inventories only the Pi agent `mcp.json`.
Skill inventory reads `~/.pi/agent/skills`, `~/.agents/skills`, the working
directory's `.pi/skills`, and `.agents/skills` from the working directory
through the git repository root.
Host-specific Cursor/Claude
files are not scanned until the adapter imports them into a Pi-owned file.

✅ Implemented · ◯ Not implemented · — Not applicable

| Feature | [Claude Code](claude_code/) | [Claude Desktop](claude_desktop/) | [Codex](codex/) | [OpenCode](opencode/) | [VS Code](vscode/) | [Ollama](ollama/) | [Cursor](cursor/) | [Grok Build](grok/) | [GitHub Copilot CLI](copilot/) |
| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| Installation and version discovery | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ |
| Local model discovery | — | — | — | — | — | ✅ | — | — | — |
| MCP server discovery | ✅ | ✅ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — |
| Skill discovery | ✅ | ◯ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — |
| Managed configuration | ✅ | ✅ | ✅ | ✅ | ✅ | ◯ | ◯ | ✅ | ✅ |
| LLM gateway routing | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ |
| Gateway credentials | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ |
| Sandbox configuration | ✅ | ◯ | ✅ | ◯ | ◯ | — | ◯ | ◯ | ◯ |
| Tool-use telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ |
| Session-start telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ |
| Feature | [Claude Code](claude_code/) | [Claude Desktop](claude_desktop/) | [Codex](codex/) | [OpenCode](opencode/) | [VS Code](vscode/) | [Ollama](ollama/) | [Cursor](cursor/) | [Grok Build](grok/) | [GitHub Copilot CLI](copilot/) | [Pi](pi/) |
| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| Installation and version discovery | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
| Local model discovery | — | — | — | — | — | ✅ | — | — | — | — |
| MCP server discovery | ✅ | ✅ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | ✅ |
| Skill discovery | ✅ | ◯ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | ✅ |
| Managed configuration | ✅ | ✅ | ✅ | ✅ | ✅ | ◯ | ◯ | ✅ | ✅ | ✅ |
| LLM gateway routing | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | ✅ |
| Gateway credentials | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | ✅ |
| Sandbox configuration | ✅ | ◯ | ✅ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ |
| Tool-use telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ |
| Session-start telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ |

- ◯ describes a gap in Agentdesktop; it does not mean the upstream provider
- ◯ describes a gap in agentdesktop; it does not mean the upstream provider
cannot support the feature.
- Managed configuration includes dry runs and removal of Agentdesktop-owned
- Managed configuration includes dry runs and removal of agentdesktop-owned
settings (no dry run for the GitHub Copilot CLI providers file and the VS
Code chat language models and settings files: their loopback proxy cannot
run in one-shot mode). Claude Code also supports merging into user settings; Claude Desktop
Expand Down
1 change: 1 addition & 0 deletions crates/agent/src/provider/copilot/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ fn options() -> json_merge::MergeOptions {
keyed_arrays: KEYED,
// The file holds the pairing and possibly the user's own API keys.
redact_diff: true,
..json_merge::MergeOptions::default()
}
}

Expand Down
75 changes: 71 additions & 4 deletions crates/agent/src/provider/json_merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ struct MergeState {
after: Value,
}

/// How an existing user file is parsed. The merged file is always written as
/// standard JSON.
#[derive(Clone, Copy)]
pub(super) enum JsonFormat {
Json,
/// JSON with comments and trailing commas; they are not kept when the
/// file is rewritten.
Jsonc,
}

impl JsonFormat {
fn parse(&self, contents: &[u8]) -> anyhow::Result<Value> {
match self {
Self::Json => Ok(serde_json::from_slice(contents)?),
Self::Jsonc => {
let contents = std::str::from_utf8(contents)?;
// Normalize commented input to ordinary JSON when writing it.
Ok(json5::from_str(contents.trim_start_matches('\u{feff}'))?)
}
}
}
}

pub(super) fn state_path(path: &Path) -> PathBuf {
let name = path
.file_name()
Expand Down Expand Up @@ -48,6 +71,11 @@ pub(super) struct MergeOptions {
/// Record actions without file content, for a file that holds secrets:
/// the dry-run report then shows the action and the path only.
pub(super) redact_diff: bool,
/// How the existing file is parsed.
pub(super) format: JsonFormat,
/// JSON pointers to values managed as a whole, rather than merged
/// additively; removal restores the user's original value.
pub(super) replace_paths: &'static [&'static str],
}

impl Default for MergeOptions {
Expand All @@ -56,6 +84,8 @@ impl Default for MergeOptions {
mode: 0o644,
keyed_arrays: &[],
redact_diff: false,
format: JsonFormat::Json,
replace_paths: &[],
}
}
}
Expand Down Expand Up @@ -130,11 +160,12 @@ pub(super) fn plan_merge_with(
}
};
// An empty (or whitespace-only) file holds nothing to keep and is not a
// conflict; the merge fills it. Comments or trailing commas are: the file
// is rewritten as plain JSON, so they would be lost silently.
// conflict; the merge fills it. Comments or trailing commas are, unless
// `options.format` accepts them: the file is rewritten as plain JSON, so
// they would be lost silently.
let mut combined = match existing.as_deref() {
Some(contents) if contents.iter().all(u8::is_ascii_whitespace) => empty_root(),
Some(contents) => match serde_json::from_slice::<Value>(contents) {
Some(contents) => match options.format.parse(contents) {
Ok(value)
if value.is_object() == managed.is_object()
&& value.is_array() == managed.is_array() =>
Expand Down Expand Up @@ -165,6 +196,14 @@ pub(super) fn plan_merge_with(
remove_keyed(&mut combined, keyed, &managed);
}
let before = combined.clone();
// Snapshot the user's original values before replacing any owned subtree.
for pointer in options.replace_paths {
if managed.pointer(pointer).is_some()
&& let Some(existing) = combined.pointer_mut(pointer)
{
*existing = Value::Null;
}
}
merge_overlay(&mut combined, managed);

let mut contents = serde_json::to_vec_pretty(&combined)
Expand Down Expand Up @@ -260,7 +299,7 @@ pub(super) fn plan_remove_with(
remove_file(state_path, display_name, plan)?;
return Ok(true);
}
let settings = match serde_json::from_slice::<Value>(&existing) {
let settings = match options.format.parse(&existing) {
Ok(value)
if value.is_object() == state.after.is_object()
&& value.is_array() == state.after.is_array() =>
Expand Down Expand Up @@ -451,6 +490,33 @@ fn rollback_value(
let after = after
.and_then(Value::as_array)
.map_or(&[][..], Vec::as_slice);
// Additive merges keep the original array as a prefix. A replacement
// must restore its order and duplicate entries, then retain user edits.
if !after.starts_with(before) {
let mut result = before.to_vec();
let mut restored = before.to_vec();
let mut added = current_array.clone();
for value in after {
if let Some(index) = restored.iter().position(|entry| entry == value) {
restored.remove(index);
}
if let Some(index) = added.iter().position(|entry| entry == value) {
added.remove(index);
} else if let Some(index) = result.iter().position(|entry| entry == value) {
// Respect a user's removal of a retained original entry.
result.remove(index);
}
}
for value in added {
if let Some(index) = restored.iter().position(|entry| entry == &value) {
// An original entry restored by the user is already present.
restored.remove(index);
} else {
result.push(value);
}
}
return Some(Value::Array(result));
}
let mut result = current_array.clone();
for added in after.iter().filter(|value| !before.contains(value)) {
if let Some(index) = result.iter().position(|value| value == added) {
Expand Down Expand Up @@ -718,6 +784,7 @@ mod tests {
mode: 0o600,
keyed_arrays: ROOT_KEYED,
redact_diff: true,
..MergeOptions::default()
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/agent/src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod json_merge;
mod metadata;
pub mod ollama;
pub mod opencode;
pub mod pi;
pub(crate) mod shared;
pub mod vscode;

Expand Down
Loading