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 (
+
+ );
+}
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"). */