diff --git a/README.md b/README.md index 196f7030..4580e4e7 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ HarnessKit manages **all five extension types** from a unified interface — **S | **Qoder CN** | ✓ | ✓ | ✓ | ✓ | ✓ | | **OpenClaw** | ✓ | ✓ | — | — | ✓ | | **PenguinHarness** | ✓ | — | — | — | — | +| **Pi** | ✓ | ✓ | ✓ | — | ✓ | * "—" means HarnessKit does not manage this extension type for the agent yet. @@ -92,7 +93,7 @@ https://github.com/user-attachments/assets/a2a74fd1-f3f2-4525-9d64-ba00378d6eef ### 🤖 Agent Configs, Memory & Rules -HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **16 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Devin Desktop**, **OpenCode**, **Hermes**, **Kiro**, **Oh My Pi**, **DeepSeek Harness**, **Grok Build**, **Qoder CN**, **OpenClaw**, and **PenguinHarness**. +HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **17 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Devin Desktop**, **OpenCode**, **Hermes**, **Kiro**, **Oh My Pi**, **DeepSeek Harness**, **Grok Build**, **Qoder CN**, **OpenClaw**, **PenguinHarness**, and **Pi**. - **Config file tracking** — Automatically discovers every agent's config files — both global and per-project. Add your project directories or custom paths and HarnessKit picks them up alongside the global ones. - **Per-agent dashboard** — Each agent gets its own page with all files organized by category, showing scope, path, file size, and a summary of installed extensions. Expand any file to preview its content right in the app. diff --git a/README.zh-CN.md b/README.zh-CN.md index bbc34b92..617938e8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -74,6 +74,7 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill* | **Qoder CN** | ✓ | ✓ | ✓ | ✓ | ✓ | | **OpenClaw** | ✓ | ✓ | — | — | ✓ | | **PenguinHarness** | ✓ | — | — | — | — | +| **Pi** | ✓ | ✓ | ✓ | — | ✓ | * "—" 表示 HarnessKit 目前还不管理该 Agent 的这类扩展。 @@ -92,7 +93,7 @@ https://github.com/user-attachments/assets/a2a74fd1-f3f2-4525-9d64-ba00378d6eef ### 🤖 Agent 配置、记忆与规则 -HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **16 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Devin Desktop**、**OpenCode**、**Hermes**、**Kiro**、**Oh My Pi**、**DeepSeek Harness**、**Grok Build**、**Qoder CN**、**OpenClaw** 与 **PenguinHarness**。 +HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **17 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Devin Desktop**、**OpenCode**、**Hermes**、**Kiro**、**Oh My Pi**、**DeepSeek Harness**、**Grok Build**、**Qoder CN**、**OpenClaw**、**PenguinHarness** 与 **Pi**。 - **配置文件跟踪** —— 自动发现每个 Agent 的全局与项目级配置文件。添加项目目录或自定义路径后,HarnessKit 会将它们与全局配置一同纳入管理。 - **Agent 专属面板** —— 每个 Agent 拥有独立页面,文件按类别组织,列出范围、路径、文件大小以及已安装扩展的概览。展开任意文件即可在应用内预览。 diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index a6286bc9..27f01fc2 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -13,6 +13,7 @@ pub mod omp; pub mod openclaw; pub mod opencode; pub mod penguin; +pub mod pi; pub mod qoder_cn; pub mod windsurf; @@ -237,6 +238,72 @@ pub(crate) fn json_string_vec(val: &serde_json::Value, key: &str) -> Vec .unwrap_or_default() } +/// Local TypeScript/JavaScript extension modules in `dir`, as omp and Pi +/// load them: a direct `*.ts`/`*.js` file, or a `/index.{ts,js}` +/// directory with TypeScript preferred. The loaders glob only those two +/// extensions, so `.mjs`/`.cjs` and any other file are never listed. +/// package.json-manifest extensions are not modeled. +/// +/// A `.disabled` suffix is HarnessKit's in-place disable rename; the loader +/// glob no longer matches it. The name is taken with the suffix stripped so +/// it stays stable across the toggle. For a directory extension the entry's +/// path is the index file, which the toggle renames, keeping the directory +/// (and so the name) stable; active index files are probed first so an +/// `index.ts.disabled` next to a live `index.js` reports what the agent +/// actually loads. +pub(crate) fn read_ts_extensions(dir: &Path) -> Vec { + fn entry(name: String, path: PathBuf, enabled: bool) -> PluginEntry { + PluginEntry { + name, + source: "local".into(), + enabled, + path: Some(path), + source_url: None, + uri: None, + installed_at: None, + updated_at: None, + base_layers: vec![], + pack: None, + } + } + let Ok(files) = std::fs::read_dir(dir) else { + return vec![]; + }; + let mut entries = Vec::new(); + for path in files.flatten().map(|f| f.path()) { + let Some(file_name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { + continue; + }; + if path.is_file() { + let enabled = !file_name.ends_with(".disabled"); + let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name); + let base = Path::new(base); + if !matches!(base.extension().and_then(|e| e.to_str()), Some("ts" | "js")) { + continue; + } + let Some(name) = base.file_stem().map(|s| s.to_string_lossy().to_string()) else { + continue; + }; + entries.push(entry(name, path, enabled)); + } else if path.is_dir() { + let candidates = [ + ("index.ts", true), + ("index.js", true), + ("index.ts.disabled", false), + ("index.js.disabled", false), + ]; + if let Some((index, enabled)) = candidates + .iter() + .map(|(f, e)| (path.join(f), *e)) + .find(|(p, _)| p.is_file()) + { + entries.push(entry(file_name, index, enabled)); + } + } + } + entries +} + /// Represents a hook entry parsed from an agent's config #[derive(Debug, Clone)] pub struct HookEntry { @@ -445,17 +512,21 @@ pub enum McpFormat { /// How an agent's config spells a remote (HTTP/SSE) MCP entry. /// /// This is the single source of truth for "which transports can this agent -/// receive": the deployer's JSON writer dispatches on the four JSON-family +/// receive": the deployer's JSON writer dispatches on the JSON-family /// variants, and `AgentCapabilities::from_adapter` derives UI install-gating -/// from it (`Toml` and `DshTransport` are the HTTP-only variants — Codex -/// and dsh have no SSE support; `GrokToml` accepts both; -/// `Unsupported` receives no remote entries at all). +/// from it (`Toml`, `DshTransport` and `PlainUrlHttpOnly` are the HTTP-only +/// variants — Codex, dsh and Pi have no SSE support; `GrokToml` accepts +/// both; `Unsupported` receives no remote entries at all). #[derive(Debug, Clone, Copy, PartialEq)] pub enum RemoteMcpSchema { /// `{type: "http"|"sse", url, headers}` — Claude, Copilot, omp. TypeAndUrl, /// `{url, headers}`, protocol auto-detected by the agent — Cursor, Kiro. PlainUrl, + /// `{url, headers}`, Streamable HTTP only — Pi. The file shape matches + /// Cursor/Kiro's PlainUrl, but Pi rejects the legacy SSE transport + /// (docs/mcp.md), so remote entries are HTTP-only. + PlainUrlHttpOnly, /// `{httpUrl}` for Streamable HTTP, `{url}` for SSE, `headers` for both — Gemini. GeminiSplit, /// `{serverUrl, headers}`, protocol auto-detected — Windsurf, Antigravity. @@ -828,9 +899,9 @@ impl crate::models::AgentCapabilities { .is_some(), global_hook_install: a.supports_global_hook_install(), vendor_baseline_packs: a.vendor_baseline_packs(), - // Codex (`Toml`) and dsh (`DshTransport`) speak Streamable HTTP - // but not SSE; GrokToml and every other non-Unsupported schema - // take both. + // Codex (`Toml`), dsh (`DshTransport`) and Pi (`PlainUrlHttpOnly`) + // speak Streamable HTTP but not SSE; GrokToml and every other + // non-Unsupported schema take both. mcp_remote: crate::models::RemoteTransportFlags { http: remote_schema != RemoteMcpSchema::Unsupported, sse: !matches!( @@ -838,6 +909,7 @@ impl crate::models::AgentCapabilities { RemoteMcpSchema::Unsupported | RemoteMcpSchema::Toml | RemoteMcpSchema::DshTransport + | RemoteMcpSchema::PlainUrlHttpOnly ), }, } @@ -875,6 +947,7 @@ pub fn all_adapters() -> Vec> { Box::new(qoder_cn::QoderCnAdapter::new()), Box::new(openclaw::OpenClawAdapter::new()), Box::new(penguin::PenguinAdapter::new()), + Box::new(pi::PiAdapter::new()), ] } @@ -921,7 +994,7 @@ mod tests { for a in all_adapters() { let caps = crate::models::AgentCapabilities::from_adapter(a.as_ref()); match a.name() { - "codex" | "dsh" => { + "codex" | "dsh" | "pi" => { assert!(caps.mcp_remote.http); assert!(!caps.mcp_remote.sse); } @@ -958,9 +1031,9 @@ mod tests { } #[test] - fn test_all_adapters_returns_sixteen() { + fn test_all_adapters_returns_seventeen() { let adapters = all_adapters(); - assert_eq!(adapters.len(), 16); + assert_eq!(adapters.len(), 17); let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect(); assert_eq!( names, @@ -981,6 +1054,7 @@ mod tests { "qoder-cn", "openclaw", "penguin", + "pi", ] ); } @@ -1004,8 +1078,7 @@ mod tests { // hurting cross-machine portability. for name in [ "claude", "codex", "gemini", "cursor", "copilot", "opencode", "hermes", "kiro", "omp", - "dsh", "grok", - "penguin", + "dsh", "grok", "penguin", "pi", ] { assert!( !by_name[name].needs_path_injection(), @@ -1022,7 +1095,7 @@ mod tests { for a in &adapters { let expected = matches!( a.name(), - "hermes" | "kiro" | "omp" | "dsh" | "grok" | "openclaw" + "hermes" | "kiro" | "omp" | "dsh" | "grok" | "openclaw" | "pi" ); assert_eq!( a.supports_native_mcp_toggle(), @@ -1061,6 +1134,7 @@ mod tests { ("qoder-cn", true, false, true, true, true), // project MCP merge pending ("openclaw", false, false, false, false, true), // global-only: gateway state dir; hooks are JS handlers ("penguin", false, false, false, false, true), // read-only discovery; no hook format, like dsh + ("pi", true, true, false, false, true), // TS/JS extensions; MCP has native per-entry enabled; no shell hooks ]; let adapters = all_adapters(); @@ -1204,6 +1278,7 @@ mod tests { ("dsh", ".dsh/skills"), ("grok", ".grok/skills"), ("qoder-cn", ".agents/skills"), // Universal standard alias, confirmed in app.asar + ("pi", ".pi/skills"), // hermes is global-only — no project skill dir (hermes-agent#4667). ] .into_iter() diff --git a/crates/hk-core/src/adapter/omp.rs b/crates/hk-core/src/adapter/omp.rs index 8f9e4a65..eca765af 100644 --- a/crates/hk-core/src/adapter/omp.rs +++ b/crates/hk-core/src/adapter/omp.rs @@ -53,42 +53,6 @@ impl OmpAdapter { serde_json::from_str(&content).ok() } - fn plugin_name(path: &Path) -> String { - // Strip `.disabled` first so a toggled-off `orca-spin.ts.disabled` - // shares the name of its enabled sibling `orca-spin.ts` — the name - // must be stable across enable/disable or HK's toggle model breaks. - // Same approach as the opencode adapter. - let file_name = path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_default(); - let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name); - Path::new(base) - .file_stem() - .map(|stem| stem.to_string_lossy().to_string()) - .unwrap_or_else(|| base.to_string()) - } - - /// omp extensions are .ts/.js modules exporting a default factory. The - /// loader globs only `*.{ts,js}` (discovery/helpers.ts, extension-loading.md) - /// — no .mjs/.cjs — so matching more here would list files omp never loads. - /// - /// A `.disabled` suffix is HK's own in-place disable rename (same model as - /// opencode plugins). omp's native disable mechanism is the - /// `disabledExtensions` id list in config.yml, which HK doesn't write; the - /// rename works because the loader's glob won't match a `.disabled` file. - fn is_extension_file(path: &Path) -> bool { - let file_name = path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_default(); - let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name); - matches!( - Path::new(base).extension().and_then(|ext| ext.to_str()), - Some("ts" | "js") - ) - } - /// Read a string-array field (e.g. `disabledServers`) from the *user* /// mcp.json. Both the denylist and the force-enable allowlist live only in /// the user file but apply to servers from every source, including project @@ -225,71 +189,19 @@ impl AgentAdapter for OmpAdapter { } fn read_plugins(&self) -> Vec { - let mut entries = Vec::new(); - for plugin_dir in self.plugin_dirs() { - let Ok(files) = std::fs::read_dir(plugin_dir) else { - continue; - }; - for file in files.flatten() { - let path = file.path(); - if path.is_file() { - if !Self::is_extension_file(&path) { - continue; - } - let enabled = path.extension().is_none_or(|ext| ext != "disabled"); - entries.push(PluginEntry { - name: Self::plugin_name(&path), - source: "local".into(), - enabled, - path: Some(path), - source_url: None, - uri: None, - installed_at: None, - updated_at: None, - base_layers: vec![], - pack: None, - }); - } else if path.is_dir() { - // Directory-form extension: /index.{ts,js}, TypeScript - // preferred (extension-loading.md resolution order). Active - // entry files first so a `index.ts.disabled` next to a live - // `index.js` reports what omp actually loads. The entry's - // path is the index file — HK's toggle renames that file, - // keeping the directory (and thus the plugin name) stable. - // package.json-manifest extensions (`omp.extensions`) are - // not modeled. - let Some(dir_name) = path.file_name().map(|n| n.to_string_lossy().to_string()) - else { - continue; - }; - let candidates = [ - ("index.ts", true), - ("index.js", true), - ("index.ts.disabled", false), - ("index.js.disabled", false), - ]; - if let Some((entry_file, enabled)) = candidates - .iter() - .map(|(f, e)| (path.join(f), *e)) - .find(|(p, _)| p.is_file()) - { - entries.push(PluginEntry { - name: dir_name, - source: "local".into(), - enabled, - path: Some(entry_file), - source_url: None, - uri: None, - installed_at: None, - updated_at: None, - base_layers: vec![], - pack: None, - }); - } - } - } - } - entries + // .ts/.js modules exporting a default factory; the loader globs only + // `*.{ts,js}` (discovery/helpers.ts, extension-loading.md). omp's + // native disable is the `disabledExtensions` id list in config.yml, + // which HK doesn't write; the `.disabled` rename works because the + // glob won't match it. + self.plugin_dirs() + .iter() + .flat_map(|dir| super::read_ts_extensions(dir)) + .collect() + } + + fn read_plugins_from(&self, dir: &Path) -> Vec { + super::read_ts_extensions(dir) } fn global_rules_files(&self) -> Vec { @@ -516,6 +428,20 @@ mod tests { assert!(!by_name["orca-dock"].enabled); } + #[test] + fn read_plugins_from_reads_project_extensions() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = OmpAdapter::with_home(tmp.path().to_path_buf()); + let ext_dir = tmp.path().join("repo/.omp/extensions"); + std::fs::create_dir_all(&ext_dir).unwrap(); + std::fs::write(ext_dir.join("repo-tool.ts"), "// ext\n").unwrap(); + + let plugins = adapter.read_plugins_from(&ext_dir); + assert_eq!(plugins.len(), 1); + assert_eq!(plugins[0].name, "repo-tool"); + assert!(adapter.read_plugins().is_empty()); + } + #[test] fn paths_match_omp_native_conventions() { let adapter = OmpAdapter::with_home(PathBuf::from("/h")); diff --git a/crates/hk-core/src/adapter/pi.rs b/crates/hk-core/src/adapter/pi.rs new file mode 100644 index 00000000..36559d2d --- /dev/null +++ b/crates/hk-core/src/adapter/pi.rs @@ -0,0 +1,375 @@ +// Pi (earendil-works/pi coding agent) — verified against upstream docs +// (github.com/earendil-works/pi, packages/coding-agent/docs): +// - Agent dir: `$PI_CODING_AGENT_DIR` or `~/.pi/agent` +// (configuration.md). Same root serves the pi CLI and pi-web +// (github.com/agegr/pi-web), the local browser UI — pi-web reuses the +// pi agent's own configuration, so there is no separate config root. +// - Skills: `~/.pi/agent/skills/` (user) + `.pi/skills/` (project). Pi also +// reads the universal Agent Skills locations `~/.agents/skills` / +// `.agents/skills` (skills.md), so those are declared here too. Pi walks +// nested directories for SKILL.md; HarnessKit lists the top level only, +// as for omp. +// - MCP: JSON `{"mcpServers": {name: {...}}}` — stdio `{command, args, env, +// cwd}` or streamable HTTP `{url, headers}` with a native per-entry +// `enabled` flag (mcp.md). User `~/.pi/agent/mcp.json`, project +// `.pi/mcp.json`. +// - Extensions: TypeScript/JavaScript modules — `~/.pi/agent/extensions/` + +// `.pi/extensions/` (extensions.md). Same shape as omp. +// - Context/rules: `AGENTS.override.md` / `AGENTS.md` / `AGENTS.MD` / +// `CLAUDE.md` / `CLAUDE.MD` from the agent dir and each ancestor of the +// working directory (configuration.md "Context files"). +// - No shell-command hooks → HookFormat::None (extensions are TS/JS modules). + +use super::{ + AgentAdapter, HookEntry, HookFormat, McpFormat, McpServerEntry, PluginEntry, ProjectMarker, + RemoteMcpSchema, +}; +use std::path::{Path, PathBuf}; + +/// Context-file fallback list, in Pi's documented precedence order. +const CONTEXT_FILENAMES: &[&str] = &[ + "AGENTS.override.md", + "AGENTS.md", + "AGENTS.MD", + "CLAUDE.md", + "CLAUDE.MD", +]; + +pub struct PiAdapter { + home: PathBuf, + agent_dir: PathBuf, +} + +impl Default for PiAdapter { + fn default() -> Self { + Self::new() + } +} + +impl PiAdapter { + pub fn new() -> Self { + let home = dirs::home_dir().unwrap_or_default(); + let agent_dir = std::env::var_os("PI_CODING_AGENT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".pi").join("agent")); + Self { home, agent_dir } + } + + #[cfg(test)] + pub fn with_home(home: PathBuf) -> Self { + Self { + agent_dir: home.join(".pi").join("agent"), + home, + } + } + + fn parse_json(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() + } +} + +impl AgentAdapter for PiAdapter { + fn name(&self) -> &str { + "pi" + } + + fn base_dir(&self) -> PathBuf { + self.home.join(".pi") + } + + fn detect(&self) -> bool { + // Pi creates the agent dir on first run (`~/.pi/agent`), including + // `~/.pi` for pi-web session state. Presence of the agent dir is the + // reliable install signal. + self.agent_dir.exists() + } + + fn skill_dirs(&self) -> Vec { + // Native Pi user skills: ~/.pi/agent/skills, plus the universal + // Agent Skills location ~/.agents/skills (skills.md: "Pi also + // supports the Agent Skills locations ~/.agents/skills/ and + // .agents/skills/"). + vec![ + self.agent_dir.join("skills"), + self.home.join(".agents").join("skills"), + ] + } + + fn mcp_config_path(&self) -> PathBuf { + // ~/.pi/agent/mcp.json is the canonical user-level file. + self.agent_dir.join("mcp.json") + } + + fn hook_config_path(&self) -> PathBuf { + // Pi has no shell-command hooks file; the trait requires a path, so + // point at mcp.json like omp does. + self.mcp_config_path() + } + + fn plugin_dirs(&self) -> Vec { + // TypeScript/JavaScript extension modules: ~/.pi/agent/extensions/*.ts + vec![self.agent_dir.join("extensions")] + } + + fn hook_format(&self) -> HookFormat { + HookFormat::None + } + + fn mcp_format(&self) -> McpFormat { + McpFormat::McpServers + } + + fn supports_native_mcp_toggle(&self) -> bool { + // Toggle flips the entry's own `enabled` flag in place + // (deployer::set_mcp_server_enabled_flag), keeping `description`, + // `timeout`, `exposure`, `cwd`, `oauth`, `env`, `headers`, and all + // other keys untouched — exactly how `pi mcp` / `/mcp` behaves. + true + } + + fn remote_mcp_schema(&self) -> RemoteMcpSchema { + // Remote entries are `{url, headers}` and always Streamable HTTP: + // Pi rejects `type: "sse"` outright (docs/mcp.md). + RemoteMcpSchema::PlainUrlHttpOnly + } + + fn read_mcp_servers(&self) -> Vec { + self.read_mcp_servers_from(&self.mcp_config_path()) + } + + fn read_mcp_servers_from(&self, path: &Path) -> Vec { + let Some(config) = Self::parse_json(path) else { + return vec![]; + }; + let Some(servers) = config.get("mcpServers").and_then(|v| v.as_object()) else { + return vec![]; + }; + servers + .iter() + // A project entry with none of `command`, `url`, `type` is not a + // server: it only overrides `enabled` / `exposure` / + // `toolExposure` of the user-level server of the same name + // (mcp.md). Reading it as a server would show an empty stdio + // entry; the override itself is not modeled yet. + .filter(|(_, val)| { + ["command", "url", "type"] + .iter() + .any(|k| val.get(k).is_some()) + }) + .map(|(name, val)| { + // `command` selects stdio, `url` selects streamable HTTP; + // an optional `type` (stdio/http/streamable-http) also counts. + let (transport, url) = super::parse_type_url(val); + McpServerEntry { + name: name.clone(), + command: val + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .into(), + args: super::json_string_vec(val, "args"), + env: super::json_string_map(val, "env"), + transport, + url, + headers: super::json_string_map(val, "headers"), + // Native per-server flag: absent means enabled. + enabled: val.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true), + } + }) + .collect() + } + + fn read_hooks(&self) -> Vec { + // Pi hooks are TS/JS event-handler extensions, not shell commands. + vec![] + } + + fn read_plugins(&self) -> Vec { + // Local .ts/.js extension modules (extensions.md). Packages installed + // with `pi install` (settings.json `packages`) are not modeled yet. + self.plugin_dirs() + .iter() + .flat_map(|dir| super::read_ts_extensions(dir)) + .collect() + } + + fn read_plugins_from(&self, dir: &Path) -> Vec { + super::read_ts_extensions(dir) + } + + fn global_rules_files(&self) -> Vec { + // Context files live directly in the agent dir (configuration.md). + CONTEXT_FILENAMES + .iter() + .map(|name| self.agent_dir.join(name)) + .collect() + } + + fn global_settings_files(&self) -> Vec { + // settings.json is the primary user-level settings file; models.json + // holds compatible endpoints; mcp.json is listed so the servers file + // is browsable from the agent page (matches the omp/kiro adapters). + vec![ + self.agent_dir.join("settings.json"), + self.agent_dir.join("models.json"), + self.mcp_config_path(), + ] + } + + fn project_markers(&self) -> Vec { + // Dir(".pi") alone suffices: every project-level Pi file lives inside + // `.pi/`. + vec![ProjectMarker::Dir(".pi")] + } + + fn project_rules_patterns(&self) -> Vec { + // Context files are discovered from the working directory and its + // ancestors (configuration.md "Context files"), not from `.pi/`. + CONTEXT_FILENAMES.iter().map(|s| s.to_string()).collect() + } + + fn project_settings_patterns(&self) -> Vec { + vec![".pi/settings.json".into()] + } + + fn project_skill_dirs(&self) -> Vec { + // Pi-native project skills plus the universal Agent Skills location + // (skills.md). + vec![".pi/skills".into(), ".agents/skills".into()] + } + + fn project_mcp_config_relpath(&self) -> Option { + // Pi reads `.pi/mcp.json`; project config is loaded after project + // trust is granted. + Some(".pi/mcp.json".into()) + } + + fn project_plugin_dirs(&self) -> Vec { + // Project-level extension modules: /.pi/extensions/*.ts + vec![".pi/extensions".into()] + } +} + +#[cfg(test)] +mod tests { + use super::super::AgentAdapter; + use super::*; + + #[test] + fn detect_requires_agent_dir() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); + assert!(!adapter.detect()); + std::fs::create_dir_all(tmp.path().join(".pi/agent")).unwrap(); + assert!(adapter.detect()); + } + + #[test] + fn read_mcp_servers_handles_stdio_and_remote() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); + let cfg = tmp.path().join(".pi/agent/mcp.json"); + std::fs::create_dir_all(cfg.parent().unwrap()).unwrap(); + std::fs::write( + &cfg, + r#"{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"] + }, + "docs": { + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer t" } + }, + "off": { "command": "echo", "enabled": false }, + "internal-tools": { "enabled": false } + } + }"#, + ) + .unwrap(); + + // `internal-tools` has no command/url/type: a project-style override + // of a server defined elsewhere, not a server of its own. + let servers = adapter.read_mcp_servers(); + assert_eq!(servers.len(), 3); + assert!(servers.iter().all(|s| s.name != "internal-tools")); + let by_name: std::collections::HashMap<&str, &McpServerEntry> = + servers.iter().map(|s| (s.name.as_str(), s)).collect(); + + assert_eq!(by_name["filesystem"].command, "npx"); + assert!(by_name["filesystem"].enabled); + + let docs = by_name["docs"]; + assert_eq!(docs.transport, super::super::McpTransport::Http); + assert_eq!(docs.url.as_deref(), Some("https://example.com/mcp")); + assert_eq!(docs.headers["Authorization"], "Bearer t"); + + assert!(!by_name["off"].enabled); + } + + #[test] + fn read_plugins_lists_ts_extensions() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); + let ext_dir = tmp.path().join(".pi/agent/extensions"); + std::fs::create_dir_all(&ext_dir).unwrap(); + std::fs::write(ext_dir.join("hello.ts"), "// ext\n").unwrap(); + std::fs::write(ext_dir.join("off.ts.disabled"), "// ext\n").unwrap(); + std::fs::write(ext_dir.join("README.md"), "# readme\n").unwrap(); + + let plugins = adapter.read_plugins(); + assert_eq!(plugins.len(), 2); + let by_name: std::collections::HashMap<&str, &PluginEntry> = + plugins.iter().map(|p| (p.name.as_str(), p)).collect(); + assert!(by_name["hello"].enabled); + assert!(!by_name["off"].enabled); + } + + #[test] + fn read_plugins_from_reads_project_extensions() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); + let ext_dir = tmp.path().join("repo/.pi/extensions"); + std::fs::create_dir_all(ext_dir.join("repo-panel")).unwrap(); + std::fs::write(ext_dir.join("repo-panel/index.ts"), "// ext\n").unwrap(); + + let plugins = adapter.read_plugins_from(&ext_dir); + assert_eq!(plugins.len(), 1); + assert_eq!(plugins[0].name, "repo-panel"); + assert_eq!(plugins[0].path, Some(ext_dir.join("repo-panel/index.ts"))); + assert!(adapter.read_plugins().is_empty()); + } + + #[test] + fn paths_match_pi_native_conventions() { + let adapter = PiAdapter::with_home(PathBuf::from("/h")); + assert_eq!(adapter.base_dir(), PathBuf::from("/h/.pi")); + assert_eq!( + adapter.skill_dirs(), + vec![ + PathBuf::from("/h/.pi/agent/skills"), + PathBuf::from("/h/.agents/skills"), + ] + ); + assert_eq!( + adapter.mcp_config_path(), + PathBuf::from("/h/.pi/agent/mcp.json") + ); + assert_eq!( + adapter.plugin_dirs(), + vec![PathBuf::from("/h/.pi/agent/extensions")] + ); + assert_eq!( + adapter.project_skill_dirs(), + vec![".pi/skills".to_string(), ".agents/skills".to_string()] + ); + assert_eq!( + adapter.project_mcp_config_relpath(), + Some(".pi/mcp.json".into()) + ); + assert_eq!(adapter.hook_format(), HookFormat::None); + assert_eq!(adapter.mcp_format(), McpFormat::McpServers); + } +} diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index 02ac497f..e5ebd8e8 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -224,7 +224,9 @@ fn validate_remote_mcp_target( RemoteMcpSchema::Unsupported => Err(HkError::Validation(format!( "{agent_name} does not support remote (HTTP/SSE) MCP servers" ))), - RemoteMcpSchema::Toml | RemoteMcpSchema::DshTransport + RemoteMcpSchema::Toml + | RemoteMcpSchema::DshTransport + | RemoteMcpSchema::PlainUrlHttpOnly if entry.transport == McpTransport::Sse => { Err(HkError::Validation(format!( @@ -259,7 +261,7 @@ fn build_mcp_json_value( obj.insert("type".into(), type_str.into()); obj.insert("url".into(), url.into()); } - RemoteMcpSchema::PlainUrl => { + RemoteMcpSchema::PlainUrl | RemoteMcpSchema::PlainUrlHttpOnly => { obj.insert("url".into(), url.into()); } RemoteMcpSchema::GeminiSplit => { @@ -304,7 +306,7 @@ fn build_mcp_json_value( } /// JSON-based MCP deploy (Claude, Gemini, Cursor, Antigravity, Copilot, -/// Windsurf, Kiro, omp). `top_key` is "mcpServers" or "servers" depending +/// Windsurf, Kiro, omp, Pi). `top_key` is "mcpServers" or "servers" depending /// on the agent; `remote` picks the agent's remote-entry spelling. fn deploy_mcp_server_json( config_path: &Path, @@ -990,6 +992,34 @@ pub fn set_kiro_mcp_enabled( }) } +/// Flip a `mcpServers..enabled` flag in place, leaving every other key +/// of the entry untouched. Absent means enabled (omp mcp-config.md, pi +/// mcp.md: "skip when false"), so enabling removes the key rather than +/// writing `true`. For agents whose mcp.json carries this native per-entry +/// flag (omp, pi). +pub fn set_mcp_server_enabled_flag( + config_path: &Path, + server_name: &str, + enabled: bool, +) -> Result<(), HkError> { + locked_modify_json(config_path, |config| { + let servers = config + .get_mut("mcpServers") + .and_then(|v| v.as_object_mut()) + .ok_or_else(|| HkError::NotFound("No mcpServers block found".into()))?; + let server = servers + .get_mut(server_name) + .and_then(|v| v.as_object_mut()) + .ok_or_else(|| HkError::NotFound(format!("MCP server '{server_name}' not found")))?; + if enabled { + server.remove("enabled"); + } else { + server.insert("enabled".into(), serde_json::Value::Bool(false)); + } + Ok(()) + }) +} + /// Flip an omp MCP server's native per-entry `enabled` flag in place, then /// scrub the user-level name list that would override the flag: on disable /// the name is removed from `enabledServers` (the force-enable allowlist @@ -1008,23 +1038,7 @@ pub fn set_omp_mcp_enabled( server_name: &str, enabled: bool, ) -> Result<(), HkError> { - locked_modify_json(entry_config_path, |config| { - let servers = config - .get_mut("mcpServers") - .and_then(|v| v.as_object_mut()) - .ok_or_else(|| HkError::NotFound("No mcpServers block found".into()))?; - let server = servers - .get_mut(server_name) - .and_then(|v| v.as_object_mut()) - .ok_or_else(|| HkError::NotFound(format!("MCP server '{server_name}' not found")))?; - if enabled { - // Absent means enabled (mcp-config.md: "skip when false"). - server.remove("enabled"); - } else { - server.insert("enabled".into(), serde_json::Value::Bool(false)); - } - Ok(()) - })?; + set_mcp_server_enabled_flag(entry_config_path, server_name, enabled)?; // A missing user file can't contain the name — don't create one just to // scrub it. if !user_config_path.exists() { @@ -3207,6 +3221,13 @@ mod tests { assert_eq!(v["url"], "https://mcp.linear.app/mcp"); assert!(v.get("type").is_none()); + // Pi spells remote entries like PlainUrl; the HTTP-only part lives in + // the validator. + let v = build_mcp_json_value(&http, RemoteMcpSchema::PlainUrlHttpOnly).unwrap(); + assert_eq!(v["url"], "https://mcp.linear.app/mcp"); + assert_eq!(v["headers"]["Authorization"], "Bearer tok"); + assert!(v.get("type").is_none()); + // Gemini spells the transport through the key itself. let v = build_mcp_json_value(&http, RemoteMcpSchema::GeminiSplit).unwrap(); assert_eq!(v["httpUrl"], "https://mcp.linear.app/mcp"); @@ -3239,6 +3260,12 @@ mod tests { assert!(matches!(&err, HkError::Validation(m) if m.contains("not SSE"))); assert!(validate_remote_mcp_target(&http, "dsh", RemoteMcpSchema::DshTransport).is_ok()); + // Pi (PlainUrlHttpOnly) rejects SSE upstream. + let err = validate_remote_mcp_target(&sse, "pi", RemoteMcpSchema::PlainUrlHttpOnly) + .unwrap_err(); + assert!(matches!(&err, HkError::Validation(m) if m.contains("not SSE"))); + assert!(validate_remote_mcp_target(&http, "pi", RemoteMcpSchema::PlainUrlHttpOnly).is_ok()); + // Remote without url is corrupt regardless of target. let mut broken = remote_entry(McpTransport::Http); broken.url = None; diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs index 927c8618..e5aa796b 100644 --- a/crates/hk-core/src/manager.rs +++ b/crates/hk-core/src/manager.rs @@ -223,6 +223,11 @@ fn toggle_mcp( &ext.name, enabled, )?; + } else if a.name() == "pi" { + // Native per-entry flag with no overriding name lists, so the + // in-place flip is the whole operation (mirrors `pi mcp` / + // `/mcp`). + deployer::set_mcp_server_enabled_flag(&config_path, &ext.name, enabled)?; } else if a.name() == "dsh" { // Official mechanism: id-targeted `disabled` override in the // home-level cordis.patch.yml (HK-managed block). Hot-reloaded @@ -738,10 +743,10 @@ fn find_disabled_plugin_path(adapter: &dyn adapter::AgentAdapter, ext_id: &str) if !file_name.ends_with(".disabled") { continue; } - // opencode and omp report single-file plugins with + // opencode, omp, and pi report single-file plugins with // source "local" (see their read_plugins), so the ID must // be rebuilt with that label, not the directory name. - let source = if matches!(adapter.name(), "opencode" | "omp") { + let source = if matches!(adapter.name(), "opencode" | "omp" | "pi") { "local".to_string() } else { plugin_dir @@ -762,7 +767,7 @@ fn find_disabled_plugin_path(adapter: &dyn adapter::AgentAdapter, ext_id: &str) // Directory-form single-entry plugins (omp: /index.{ts,js}) // — the disabled marker is the renamed index file inside the // directory, and the plugin is named after the directory. - if adapter.name() == "omp" { + if matches!(adapter.name(), "omp" | "pi") { for index_name in ["index.ts.disabled", "index.js.disabled"] { let disabled = entry.path().join(index_name); if !disabled.exists() { diff --git a/src/components/extensions/extension-filters.tsx b/src/components/extensions/extension-filters.tsx index aee5b11c..10afb7cb 100644 --- a/src/components/extensions/extension-filters.tsx +++ b/src/components/extensions/extension-filters.tsx @@ -52,6 +52,7 @@ const AGENT_FILTER_COLORS: Record = { "bg-agent-qoder-cn/10 text-agent-qoder-cn border-agent-qoder-cn/30", openclaw: "bg-agent-openclaw/10 text-agent-openclaw border-agent-openclaw/30", penguin: "bg-agent-penguin/10 text-agent-penguin border-agent-penguin/30", + pi: "bg-agent-pi/10 text-agent-pi border-agent-pi/30", }; export function ExtensionFilters() { diff --git a/src/components/onboarding/onboarding.tsx b/src/components/onboarding/onboarding.tsx index 7673a4b5..42caa84e 100644 --- a/src/components/onboarding/onboarding.tsx +++ b/src/components/onboarding/onboarding.tsx @@ -271,6 +271,7 @@ const FLOAT_DELAYS: Record<(typeof AGENT_ORDER)[number], number> = { "qoder-cn": 0.5, openclaw: 1.2, penguin: 2.5, + pi: 2.3, }; const SCATTER_POSITIONS: Record< (typeof AGENT_ORDER)[number], @@ -292,6 +293,7 @@ const SCATTER_POSITIONS: Record< "qoder-cn": { x: 180, y: 100, r: -14 }, openclaw: { x: -170, y: -110, r: 16 }, penguin: { x: -130, y: 145, r: 16 }, + pi: { x: -80, y: 160, r: -18 }, }; function HandAnnotation({ diff --git a/src/components/shared/agent-card.tsx b/src/components/shared/agent-card.tsx index 0da65e7c..982f1535 100644 --- a/src/components/shared/agent-card.tsx +++ b/src/components/shared/agent-card.tsx @@ -24,6 +24,8 @@ const CLICK_DURATIONS: Partial> = { openclaw: 3050, // shake and wake scatter share one 1.6s track penguin: 1650, + // slide together, hold, puff away, pause and fade back: 3s plus the last leg's delay + pi: 3200, }; export function AgentCard({ agent }: AgentCardProps) { diff --git a/src/components/shared/agent-mascot/agent-mascot.tsx b/src/components/shared/agent-mascot/agent-mascot.tsx index f2d9fb95..b32c19aa 100644 --- a/src/components/shared/agent-mascot/agent-mascot.tsx +++ b/src/components/shared/agent-mascot/agent-mascot.tsx @@ -15,6 +15,7 @@ import { OmpMascot } from "./omp-mascot"; import { OpenClawMascot } from "./openclaw-mascot"; import { OpencodeMascot } from "./opencode-mascot"; import { PenguinMascot } from "./penguin-mascot"; +import { PiMascot } from "./pi-mascot"; import { QoderCnMascot } from "./qoder-cn-mascot"; interface AgentMascotProps { @@ -106,6 +107,7 @@ const MASCOT_MAP: Record< className: "mascot-penguin", scale: 1.1, }, + pi: { component: PiMascot, className: "mascot-pi", scale: 0.9 }, }; export function AgentMascot({ diff --git a/src/components/shared/agent-mascot/mascot.css b/src/components/shared/agent-mascot/mascot.css index de88262f..db64c474 100644 --- a/src/components/shared/agent-mascot/mascot.css +++ b/src/components/shared/agent-mascot/mascot.css @@ -2370,6 +2370,133 @@ } /* === Reduced Motion === */ +/* ===== Pi ===== */ +/* Each block moves about its own centre. --tx/--ty is where it goes on + click: the three slide into a 3×3 square with the blue leg's foot left + over below it, the yellow bar travelling furthest. */ +.mascot-pi path, +.mascot-pi .pi-puff { + transform-box: fill-box; + transform-origin: center; +} +.mascot-pi path { + stroke-width: 0.6; + stroke-linejoin: miter; +} +.mascot-pi .pi-bar { + --tx: 5px; + --ty: 0; +} +.mascot-pi .pi-left { + --tx: 15px; + --ty: 0; +} +.mascot-pi .pi-right { + --tx: -25px; + --ty: -10px; +} +.mascot-pi .pi-puff { + fill: #bdbdbd; + opacity: 0; + filter: blur(0.8px); +} + +/* Hover: the blocks get picked up one after another. Each darker twin + slides out towards the bottom right as the block's side face while the + block itself lifts a little, so the flat mark reads as blocks with + thickness; leaving the card presses them flat again. Transitions rather + than a loop, so the state simply holds while the pointer stays. */ +.mascot-pi .pi-depth, +.mascot-pi .pi-piece { + --rx: 0; + --ry: 0; + transform: translate(var(--rx), var(--ry)); + transition: transform 0.4s cubic-bezier(0.34, 1.3, 0.64, 1); +} +.mascot-pi .pi-left { + transition-delay: 0.12s; +} +.mascot-pi .pi-right { + transition-delay: 0.24s; +} +.mascot-pi.is-animated .pi-depth { + --rx: 2.2px; + --ry: 2.2px; +} +.mascot-pi.is-animated .pi-piece { + --rx: -0.6px; + --ry: -0.6px; +} + +/* Click: the blocks slide into the square, which holds, then swells, blurs + and is gone in a puff of smoke, and the π fades back in; the legs start + a beat after the bar. Each side face runs the same animation as its + block so it stays hidden underneath. After the hover rules so a click + mid-hover takes the blocks over. The last frame ends on the hover + resting offset: removing an animation never starts a transition, so + this is what lets the blocks come back picked up, gradually, when the + pointer is still over the card. */ +.mascot-pi.is-clicked .pi-depth, +.mascot-pi.is-clicked .pi-piece { + animation: pi-assemble 3s both; +} +.mascot-pi.is-clicked .pi-left { + animation-delay: 0.08s; +} +.mascot-pi.is-clicked .pi-right { + animation-delay: 0.16s; +} +.mascot-pi.is-clicked .pi-puff { + /* no fill mode: a backwards fill would show the first frame through the + whole delay, as a dot at the centre of the square */ + animation: pi-puff 0.9s ease-out calc(1.35s + var(--delay)); +} + +@keyframes pi-assemble { + 0% { + transform: none; + opacity: 1; + filter: blur(0); + animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1); + } + 30%, + 45% { + transform: translate(var(--tx), var(--ty)); + opacity: 1; + filter: blur(0); + animation-timing-function: ease-out; + } + 62% { + transform: translate(var(--tx), var(--ty)) scale(1.35); + opacity: 0; + filter: blur(1.8px); + } + /* half a second of nothing before the π comes back */ + 63%, + 80% { + transform: none; + opacity: 0; + filter: blur(0); + animation-timing-function: ease-out; + } + 100% { + transform: translate(var(--rx), var(--ry)); + opacity: 1; + filter: blur(0); + } +} + +@keyframes pi-puff { + 0% { + transform: scale(0.4); + opacity: 0.8; + } + 100% { + transform: translate(var(--px), var(--py)) scale(2.8); + opacity: 0; + } +} + @media (prefers-reduced-motion: reduce) { /* The bare `.is-animated` selectors below are not redundant with the `*` ones: `*` only matches descendants, so a mascot that animates its own @@ -2395,6 +2522,7 @@ .mascot-qoder-cn.is-animated *, .mascot-openclaw.is-animated *, .mascot-penguin.is-animated *, + .mascot-pi.is-animated *, .mascot-fallback.is-animated *, .mascot-claude.is-clicked, .mascot-claude.is-clicked *, @@ -2423,6 +2551,7 @@ .mascot-qoder-cn.is-clicked *, .mascot-openclaw.is-clicked *, .mascot-penguin.is-clicked *, + .mascot-pi.is-clicked *, .mascot-fallback.is-clicked * { animation: none; transition: none; diff --git a/src/components/shared/agent-mascot/pi-mascot.tsx b/src/components/shared/agent-mascot/pi-mascot.tsx new file mode 100644 index 00000000..8b0bfd1c --- /dev/null +++ b/src/components/shared/agent-mascot/pi-mascot.tsx @@ -0,0 +1,86 @@ +// Pi mascot — the upstream brand mark (pi.dev/logo-auto.svg): a pixel π +// built from three blocks on a 4×4 grid, a coral bar, a blue left leg and +// a yellow right leg. Drawn at 10 units per cell without a tile, which also +// keeps it apart from omp's gradient π on a dark tile. Each block has a +// darker twin behind it (.pi-depth) that sits exactly under it at rest and +// slides out on hover as the block's side face, so the flat mark turns into +// blocks with thickness. Blocks paint a same-colour stroke 0.3 units past +// their edges to cover the anti-aliased seam where two of them meet. +// Motion lives on the .mascot-pi wrapper in mascot.css: hover lifts the +// blocks one by one; click slides them together into a 3×3 square with one +// cell left over, holds, then the square puffs away in a burst of smoke +// and the π fades back in. + +interface MascotSvgProps { + size: number; +} + +const BLOCKS = [ + { cls: "pi-bar", d: "M0 0h30v20H20V10H0z", fill: "#F09082", side: "#B5665B" }, + { + cls: "pi-left", + d: "M0 10h10v10h10v10H10v10H0z", + fill: "#4D9ABF", + side: "#33708C", + }, + { cls: "pi-right", d: "M30 20h10v20H30z", fill: "#F1BE58", side: "#B58A3A" }, +]; + +// Smoke puffs for the vanish, in viewBox units around the square's centre. +// Each drifts along its own vector so the burst reads as a puff rather +// than a ring; fixed so every click looks the same. +const PUFFS = [ + { px: -16, py: -13, delay: 0 }, + { px: 15, py: -15, delay: 0.04 }, + { px: 18, py: 6, delay: 0.02 }, + { px: -14, py: 13, delay: 0.06 }, + { px: 0, py: -19, delay: 0.08 }, + { px: 12, py: 17, delay: 0.05 }, +]; + +export function PiMascot({ size }: MascotSvgProps) { + return ( + + {BLOCKS.map((b) => ( + + ))} + {BLOCKS.map((b) => ( + + ))} + {PUFFS.map((p) => ( + + ))} + + ); +} diff --git a/src/index.css b/src/index.css index 0db42703..24098c4a 100644 --- a/src/index.css +++ b/src/index.css @@ -104,6 +104,7 @@ --agent-qoder-cn: oklch(0.52 0.11 195); --agent-openclaw: oklch(0.62 0.19 35); --agent-penguin: oklch(0.5 0.2 262); + --agent-pi: oklch(0.52 0.06 82); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.72 0.16 155); --toast-success-text: oklch(0.4 0.1 155); @@ -214,6 +215,7 @@ --agent-qoder-cn: oklch(0.76 0.1 195); --agent-openclaw: oklch(0.7 0.17 35); --agent-penguin: oklch(0.74 0.14 262); + --agent-pi: oklch(0.9 0.045 85); --toast-success-bg: oklch(0.23 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -318,6 +320,7 @@ --agent-qoder-cn: oklch(0.52 0.11 195); --agent-openclaw: oklch(0.62 0.19 35); --agent-penguin: oklch(0.5 0.2 262); + --agent-pi: oklch(0.52 0.06 82); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.65 0.15 155); --toast-success-text: oklch(0.4 0.1 155); @@ -416,6 +419,7 @@ --agent-qoder-cn: oklch(0.76 0.1 195); --agent-openclaw: oklch(0.7 0.17 35); --agent-penguin: oklch(0.74 0.14 262); + --agent-pi: oklch(0.9 0.045 85); --toast-success-bg: oklch(0.26 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -526,6 +530,7 @@ --agent-qoder-cn: oklch(0.52 0.11 195); --agent-openclaw: oklch(0.62 0.19 35); --agent-penguin: oklch(0.5 0.2 262); + --agent-pi: oklch(0.52 0.06 82); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.72 0.16 155); --toast-success-text: oklch(0.4 0.1 155); @@ -630,6 +635,7 @@ --agent-qoder-cn: oklch(0.76 0.1 195); --agent-openclaw: oklch(0.7 0.17 35); --agent-penguin: oklch(0.74 0.14 262); + --agent-pi: oklch(0.9 0.045 85); --toast-success-bg: oklch(0.23 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -712,6 +718,7 @@ html.dark[data-theme="tiesen"][data-web="true"] { --color-agent-qoder-cn: var(--agent-qoder-cn); --color-agent-openclaw: var(--agent-openclaw); --color-agent-penguin: var(--agent-penguin); + --color-agent-pi: var(--agent-pi); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); diff --git a/src/lib/__tests__/types.test.ts b/src/lib/__tests__/types.test.ts index b4cd4079..f507694a 100644 --- a/src/lib/__tests__/types.test.ts +++ b/src/lib/__tests__/types.test.ts @@ -334,9 +334,9 @@ describe("sortAgentNames", () => { }); describe("AGENT_ORDER", () => { - it("lists 16 agents with penguin last", () => { - expect(AGENT_ORDER).toHaveLength(16); - expect(AGENT_ORDER[AGENT_ORDER.length - 1]).toBe("penguin"); + it("lists 17 agents with pi last", () => { + expect(AGENT_ORDER).toHaveLength(17); + expect(AGENT_ORDER[AGENT_ORDER.length - 1]).toBe("pi"); }); }); diff --git a/src/lib/types.ts b/src/lib/types.ts index 0c60cdbc..d9eae702 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -436,6 +436,7 @@ export const AGENT_ORDER = [ "qoder-cn", "openclaw", "penguin", + "pi", ] as const; /** Sort an array of agents (or agent-like objects with a `name` field) by a given order. */ @@ -467,6 +468,7 @@ const AGENT_DISPLAY_NAMES: Record = { "qoder-cn": "Qoder CN", openclaw: "OpenClaw", penguin: "Penguin", + pi: "Pi", }; /** Get the display name for an agent (e.g. "claude" → "Claude Code"). */