diff --git a/.agents/docs/agent-adapters.md b/.agents/docs/agent-adapters.md index 5efa0d51..e15a5acc 100644 --- a/.agents/docs/agent-adapters.md +++ b/.agents/docs/agent-adapters.md @@ -49,7 +49,7 @@ Every provider is a folder under `src/supervisor/agents//` with the same i Opening two provider folders side-by-side answers "what does this provider do differently" by file-name alignment alone. -Model/effort lists below are the **statically declared defaults**. Several providers ship `models: []` / `efforts: []` and fill them at runtime from a capabilities probe (Codex/Gemini/Copilot/Grok/OpenCode, and Cursor via its CLI `--list-models`) — the listed values are illustrative, not authoritative. Read the provider's `detection.ts` for the live source of truth. +Model/effort lists below are the **statically declared defaults**. Several providers ship `models: []` / `efforts: []` and fill them at runtime from a capabilities probe (Codex/Gemini/Copilot/Grok/OpenCode/Pi, and Cursor via its CLI `--list-models`) — the listed values are illustrative, not authoritative. Read the provider's `detection.ts` for the live source of truth. The **Structured Session** column reflects whether the adapter implements `createStructuredSession` (i.e. supports a `"gui"` presentation mode); it is not a model-list default and is authoritative. @@ -62,6 +62,7 @@ The **Structured Session** column reflects whether the adapter implements `creat | Cursor | auto, composer-\*, GPT/Opus/Sonnet variants (probed via `--list-models`) | (embedded in model name) | terminal | Yes (ACP) | | Grok | grok-build (probed via ACP) | (none) | terminal | Yes (ACP) | | OpenCode | (probed dynamically via SDK) | (probed dynamically) | terminal / GUI server | Yes (SDK server) | +| Pi | (authenticated models probed via SDK) | off…max, per model | terminal | Yes (native SDK) | | Antigravity | auto (managed by `agy`) | (none) | terminal | No | | Command Code | Kimi/Claude/GPT/Gemini/GLM/… (static, `--list-models`) | (none) | terminal | No | @@ -147,7 +148,9 @@ most often forgotten. ### 5. Tests & verification - [ ] `tests/integration/providers-lifecycle.integration.test.ts` — add a - `PREFERRED_MODEL` entry (auto-skips when the CLI is not installed). + `PREFERRED_MODEL` entry, or explicitly use its detected-model fallback when + the provider has no universally available model (auto-skips when the CLI is + not installed). - [ ] Run green: `pnpm run typecheck`, `pnpm run lint`, targeted `pnpm exec vitest run`, and `pnpm exec oxfmt --check `. diff --git a/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs b/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs index e7bbfb41..807625ef 100644 --- a/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs +++ b/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs @@ -1344,6 +1344,7 @@ async function runMockGate(client, gate, fixture) { cursor: "slash", grok: "slash", antigravity: "prompt", + pi: "skill", }; for (const [agentKind, invocation] of Object.entries(expected)) { const result = await bridgeInvoke(client, "scanSkills", { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9dbbdd45..eace00e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ packages: enableGlobalVirtualStore: true allowBuilds: + "@google/genai": false "@sentry/cli": true better-sqlite3: true electron: true diff --git a/scripts/build-desktop-artifact.mjs b/scripts/build-desktop-artifact.mjs index 6db6e880..11d0b26b 100644 --- a/scripts/build-desktop-artifact.mjs +++ b/scripts/build-desktop-artifact.mjs @@ -42,6 +42,7 @@ const { supportEmail } = requireFromHere("../branding/contact.json"); const RUNTIME_DEPS = [ "@agentclientprotocol/sdk", "@anthropic-ai/claude-agent-sdk", + "@earendil-works/pi-coding-agent", "@modelcontextprotocol/sdk", "@opencode-ai/sdk", "@sentry/electron", diff --git a/src/renderer/components/providers/pi/PiIcon.tsx b/src/renderer/components/providers/pi/PiIcon.tsx new file mode 100644 index 00000000..b797bad9 --- /dev/null +++ b/src/renderer/components/providers/pi/PiIcon.tsx @@ -0,0 +1,14 @@ +import { createProviderIcon } from "../common/createProviderIcon"; + +// Official compact Pi badge mark from https://pi.dev/press-kit. +const PI_MAIN_PATH = + "M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"; +const PI_DOT_PATH = "M517.36 400H634.72V634.72H517.36Z"; + +export const PiIcon = createProviderIcon({ + cssPrefix: "poracode-pi-icon", + path: PI_MAIN_PATH, + secondaryPath: PI_DOT_PATH, + fillRule: "evenodd", + viewBox: "0 0 800 800", +}); diff --git a/src/renderer/components/providers/pi/index.tsx b/src/renderer/components/providers/pi/index.tsx new file mode 100644 index 00000000..42ef1cdd --- /dev/null +++ b/src/renderer/components/providers/pi/index.tsx @@ -0,0 +1,22 @@ +export * from "./PiIcon"; + +import { PiIcon } from "./PiIcon"; +import providerManifest from "./manifest"; +import { registerProviderIcon } from "../ProviderIcon"; +import { registerCommitGenDefaults } from "../commitGen"; +import { registerConflictResolverDefaults } from "../conflictResolver"; +import { registerComposerControls } from "../providerComposer"; +import { registerTitleGenDefaults } from "../titleGen"; + +// Pi intentionally has no core plan mode or permission policy. Model and +// thinking controls come from the dynamically detected capabilities; optional +// extension commands are published by the live SDK session. +registerProviderIcon(providerManifest.kind, PiIcon); +// Pi has no universal model: its catalog is the authenticated providers in the +// user's ModelRegistry. Empty defaults intentionally select the first detected +// model at runtime instead of advertising a model that may not be configured. +const utilityDefaults = { label: "Pi", model: "", effort: "" }; +registerCommitGenDefaults(providerManifest.kind, utilityDefaults); +registerTitleGenDefaults(providerManifest.kind, utilityDefaults); +registerConflictResolverDefaults(providerManifest.kind, utilityDefaults); +registerComposerControls(providerManifest.kind, () => []); diff --git a/src/renderer/components/providers/pi/manifest.ts b/src/renderer/components/providers/pi/manifest.ts new file mode 100644 index 00000000..fda228de --- /dev/null +++ b/src/renderer/components/providers/pi/manifest.ts @@ -0,0 +1,8 @@ +import { msg } from "@lingui/core/macro"; +import type { RendererProviderManifest } from "../providerManifest"; + +export default { + kind: "pi", + label: msg`Pi`, + order: 75, +} satisfies RendererProviderManifest; diff --git a/src/renderer/components/providers/providerManifest.test.ts b/src/renderer/components/providers/providerManifest.test.ts index 76f37f90..ad643f7a 100644 --- a/src/renderer/components/providers/providerManifest.test.ts +++ b/src/renderer/components/providers/providerManifest.test.ts @@ -26,6 +26,7 @@ const EXPECTED_PROVIDER_ORDER = [ "antigravity", "commandcode", "opencode", + "pi", "cursor", "copilot", "factory", @@ -47,6 +48,7 @@ describe("renderer provider manifests", () => { expect(getProviderManifest("zai")).toBeUndefined(); expect(i18n._(getProviderManifest("copilot")!.label)).toBe("GitHub Copilot"); expect(i18n._(getProviderManifest("factory")!.label)).toBe("Factory Droid"); + expect(i18n._(getProviderManifest("pi")!.label)).toBe("Pi"); }); it("inherits base-provider ranks for scoped kinds and leaves unknown providers at the tail", () => { diff --git a/src/renderer/components/skills/useSkills.test.ts b/src/renderer/components/skills/useSkills.test.ts index a00d2568..35ba31f5 100644 --- a/src/renderer/components/skills/useSkills.test.ts +++ b/src/renderer/components/skills/useSkills.test.ts @@ -21,6 +21,7 @@ const invocationByProvider = { cursor: "slash", grok: "slash", antigravity: "prompt", + pi: "skill", } as const; function emptyScan(): SkillScanResult { @@ -101,9 +102,11 @@ describe("buildSkillSlashCommands", () => { skillInvocation: invocation === "dollar" ? "$unique-managed-skill" - : invocation === "prompt" - ? "Use the unique-managed-skill skill." - : "/unique-managed-skill", + : invocation === "skill" + ? "/skill:unique-managed-skill" + : invocation === "prompt" + ? "Use the unique-managed-skill skill." + : "/unique-managed-skill", }), ]); expect(provider).toBeTruthy(); diff --git a/src/renderer/components/skills/useSkills.ts b/src/renderer/components/skills/useSkills.ts index 3e8f9de3..cd042446 100644 --- a/src/renderer/components/skills/useSkills.ts +++ b/src/renderer/components/skills/useSkills.ts @@ -116,9 +116,11 @@ export function buildSkillSlashCommands(scan: SkillScanResult | null): AgentSlas const invocation = scan.invocation === "dollar" ? `$${skill.name}` - : scan.invocation === "prompt" - ? `Use the ${skill.name} skill.` - : `/${skill.name}`; + : scan.invocation === "skill" + ? `/skill:${skill.name}` + : scan.invocation === "prompt" + ? `Use the ${skill.name} skill.` + : `/${skill.name}`; return [ { id: skill.name, diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index e9ea5b3f..71074460 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -4172,6 +4172,10 @@ msgstr "Erstklassige Kimi Code CLI-Integration mit der nativen Laufzeit von Pora msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Erstklassige OpenCode-Integration mithilfe der nativen SDK-Laufzeitumgebung von Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "Erstklassige Pi-Integration mit einem echten Terminal und Pis nativer SDK-Laufzeit." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Erstklassige Qwen Code-Integration mit Poracodes nativen Terminal- und ACP-Laufzeiten." @@ -7050,6 +7054,10 @@ msgstr "Ausstehende Lenkung" msgid "permission" msgstr "Berechtigung" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index 44abc086..a1d2a5aa 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -4172,6 +4172,10 @@ msgstr "First-class Kimi Code CLI integration using Poracode's native runtime." msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "First-class OpenCode integration using Poracode's native SDK runtime." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "First-class Pi integration using a real terminal and Pi's native SDK runtime." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." @@ -7050,6 +7054,10 @@ msgstr "Pending steer" msgid "permission" msgstr "permission" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index fdefd358..de659e2a 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -4172,6 +4172,10 @@ msgstr "Integración de primera clase de Kimi Code CLI usando el runtime nativo msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Integración de primera clase de OpenCode usando el runtime nativo del SDK de Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "Integración Pi de primera clase con un terminal real y el entorno de ejecución SDK nativo de Pi." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Integración de primera clase con Qwen Code mediante los entornos de ejecución nativos de terminal y ACP de Poracode." @@ -7050,6 +7054,10 @@ msgstr "Dirección pendiente" msgid "permission" msgstr "permiso" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index b6f56b87..2d7c49b8 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -4172,6 +4172,10 @@ msgstr "Intégration CLI Kimi Code de première classe à l'aide du runtime nati msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Intégration OpenCode de première classe à l'aide du runtime SDK natif de Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "Intégration Pi de premier ordre avec un véritable terminal et l’environnement d’exécution SDK natif de Pi." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Intégration de premier ordre de Qwen Code utilisant les environnements d’exécution natifs de terminal et ACP de Poracode." @@ -7049,6 +7053,10 @@ msgstr "En attente de direction" msgid "permission" msgstr "autorisation" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index d6103255..2cf52df7 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -4171,6 +4171,10 @@ msgstr "Poracode のネイティブ ランタイムを使用したファース msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Poracode のネイティブ SDK ランタイムを使用したファーストクラスの OpenCode 統合。" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "実際のターミナルと Pi のネイティブ SDK ランタイムを使用する、完全な Pi 統合です。" + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Poracode のネイティブなターミナルおよび ACP ランタイムを使用する、Qwen Code のファーストクラス統合です。" @@ -7048,6 +7052,10 @@ msgstr "保留中のステアリング" msgid "permission" msgstr "許可" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index d5f56a57..d3f23383 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -4172,6 +4172,10 @@ msgstr "Poracode의 기본 런타임을 사용하는 최고 수준의 Kimi Code msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Poracode의 기본 SDK 런타임을 사용한 최고 수준의 OpenCode 통합." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "실제 터미널과 Pi의 네이티브 SDK 런타임을 사용하는 완전한 Pi 통합입니다." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Poracode의 네이티브 터미널 및 ACP 런타임을 사용하는 Qwen Code의 일급 통합입니다." @@ -7050,6 +7054,10 @@ msgstr "조정 대기 중" msgid "permission" msgstr "권한" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index ce92a1ca..d428e212 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -4172,6 +4172,10 @@ msgstr "Pierwszorzędna integracja Kimi Code CLI przy użyciu natywnego środowi msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Pierwszorzędna integracja OpenCode z wykorzystaniem natywnego środowiska wykonawczego SDK Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "Pełna integracja Pi korzystająca z prawdziwego terminala i natywnego środowiska uruchomieniowego SDK Pi." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Pierwszorzędna integracja z Qwen Code korzystająca z natywnych środowisk uruchomieniowych terminala i ACP w Poracode." @@ -7050,6 +7054,10 @@ msgstr "Oczekujące sterowanie" msgid "permission" msgstr "pozwolenie" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index d936dc74..4efc3df8 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -4172,6 +4172,10 @@ msgstr "Integração Kimi Code CLI de primeira classe usando o tempo de execuç msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Integração OpenCode de primeira classe usando o tempo de execução SDK nativo do Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "Integração Pi de primeira classe com um terminal real e o ambiente de execução SDK nativo do Pi." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Integração nativa com o Qwen Code usando os runtimes de terminal e ACP do Poracode." @@ -7050,6 +7054,10 @@ msgstr "Orientação pendente" msgid "permission" msgstr "permissão" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index 6007cf4c..fa19717a 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -4172,6 +4172,10 @@ msgstr "Первоклассная интеграция Kimi Code CLI с исп msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Первоклассная интеграция OpenCode с использованием нативной среды выполнения SDK Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "Полноценная интеграция Pi с настоящим терминалом и нативной средой выполнения SDK Pi." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Первоклассная интеграция Qwen Code с использованием встроенных сред терминала и ACP в Poracode." @@ -7050,6 +7054,10 @@ msgstr "Ожидающее направление" msgid "permission" msgstr "разрешение" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index 486d0fbc..90b6e566 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -4172,6 +4172,10 @@ msgstr "Poracode'un yerel çalışma zamanını kullanan birinci sınıf Kimi Co msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Poracode'un yerel SDK çalışma zamanını kullanan birinci sınıf OpenCode entegrasyonu." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "Gerçek bir terminal ve Pi'nin yerel SDK çalışma zamanı ile birinci sınıf Pi entegrasyonu." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Poracode'un yerel terminal ve ACP çalışma zamanlarını kullanan birinci sınıf Qwen Code entegrasyonu." @@ -7050,6 +7054,10 @@ msgstr "Yönlendirme bekleniyor" msgid "permission" msgstr "izin" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index 41402100..046c1267 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -4172,6 +4172,10 @@ msgstr "Першокласна інтеграція Kimi Code CLI з викор msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Першокласна інтеграція OpenCode з використанням нативного середовища виконання SDK Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "Повноцінна інтеграція Pi зі справжнім терміналом і нативним середовищем виконання SDK Pi." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Повноцінна інтеграція Qwen Code із використанням вбудованих середовищ термінала та ACP у Poracode." @@ -7050,6 +7054,10 @@ msgstr "Очікуване скерування" msgid "permission" msgstr "дозвіл" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index d6740f5d..3d828e00 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -4172,6 +4172,10 @@ msgstr "Tích hợp Kimi Code CLI hạng nhất bằng cách sử dụng thời msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Tích hợp OpenCode hạng nhất bằng thời gian chạy SDK gốc của Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "Tích hợp Pi đầy đủ bằng thiết bị đầu cuối thực và môi trường chạy SDK gốc của Pi." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Tích hợp Qwen Code hạng nhất bằng các môi trường chạy terminal và ACP gốc của Poracode." @@ -7050,6 +7054,10 @@ msgstr "Đang chờ chỉ đạo" msgid "permission" msgstr "sự cho phép" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index a77913d2..ecd015ab 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -4172,6 +4172,10 @@ msgstr "使用 Poracode 的本机运行时进行一流的 Kimi Code CLI 集成 msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "使用 Poracode 的本机 SDK 运行时进行一流的 OpenCode 集成。" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." +msgstr "使用真实终端和 Pi 原生 SDK 运行时的完整 Pi 集成。" + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "使用 Poracode 原生终端和 ACP 运行时的一流 Qwen Code 集成。" @@ -7049,6 +7053,10 @@ msgstr "等待引导" msgid "permission" msgstr "权限" +#: src/renderer/components/providers/pi/manifest.ts +msgid "Pi" +msgstr "Pi" + #: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx b/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx index b91b9291..73407100 100644 --- a/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx +++ b/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx @@ -126,6 +126,7 @@ describe("native ACP registry aliases", () => { "github-copilot-cli": "copilot", "grok-build": "grok", opencode: "opencode", + "pi-acp": "pi", }); }); diff --git a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts index 6d31c4c9..33274d99 100644 --- a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +++ b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts @@ -152,6 +152,27 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [ settingsPanel: OpenCodeProviderSettings, ownsAuthUi: true, }, + { + id: "pi", + acpRegistryAliases: [{ id: "pi-acp" }], + description: msg`First-class Pi integration using a real terminal and Pi's native SDK runtime.`, + docsUrl: "https://pi.dev/docs/latest", + installCommand: (project) => + nativeInstallCommand(project, { + mac: + "if command -v curl >/dev/null 2>&1; then curl -fsSL https://pi.dev/install.sh | sh; " + + "elif command -v npm >/dev/null 2>&1; then npm install -g @earendil-works/pi-coding-agent; else " + + POSIX_MISSING_CURL_NPM_MESSAGE + + "; fi", + posix: + "if command -v curl >/dev/null 2>&1; then curl -fsSL https://pi.dev/install.sh | sh; " + + "elif command -v npm >/dev/null 2>&1; then npm install -g @earendil-works/pi-coding-agent; else " + + POSIX_MISSING_CURL_NPM_MESSAGE + + "; fi", + windows: + "if (Get-Command irm -ErrorAction SilentlyContinue) { irm https://pi.dev/install.ps1 | iex } elseif (Get-Command npm -ErrorAction SilentlyContinue) { npm install -g @earendil-works/pi-coding-agent } else { Write-Host 'No supported installer found. Install PowerShell Invoke-RestMethod or Node.js/npm first, then refresh detected agents.' }", + }), + }, { id: "grok", acpRegistryAliases: [{ id: "grok-build" }], diff --git a/src/shared/contracts/skill.ts b/src/shared/contracts/skill.ts index bc960901..bfd2374f 100644 --- a/src/shared/contracts/skill.ts +++ b/src/shared/contracts/skill.ts @@ -78,7 +78,7 @@ export type SkillScanIssue = z.infer; export const skillScanResultSchema = z.object({ skills: z.array(skillEntrySchema), effectiveSkillIds: z.array(z.string()), - invocation: z.enum(["slash", "dollar", "prompt"]).nullable(), + invocation: z.enum(["slash", "dollar", "prompt", "skill"]).nullable(), issues: z.array(skillScanIssueSchema), canLinkToGlobal: z.boolean(), }); diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index e3d7955e..691bebcf 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -565,7 +565,8 @@ export interface AgentSkillSupport { readonly roots: readonly AgentSkillRootSpec[]; /** Provider roots that need a Poracode-owned copy of canonical skills. */ readonly projectionRoots?: readonly AgentSkillRootSpec[]; - readonly invocation: "slash" | "dollar" | "prompt"; + /** How the provider invokes a named skill from its composer. */ + readonly invocation: "slash" | "dollar" | "prompt" | "skill"; /** Provider-native duplicate resolution, using root ids plus canonical `agents`. */ readonly precedence?: { readonly scopeOrder?: readonly ("global" | "project")[]; diff --git a/src/supervisor/agents/oneShotCapability.test.ts b/src/supervisor/agents/oneShotCapability.test.ts index 604c5dba..6720e97f 100644 --- a/src/supervisor/agents/oneShotCapability.test.ts +++ b/src/supervisor/agents/oneShotCapability.test.ts @@ -42,7 +42,7 @@ describe("supportsOneShot capability", () => { .filter((adapter) => adapter.capabilities.supportsTextOnlyOneShot === true) .map((adapter) => adapter.kind) .sort(); - expect(supported).toEqual(["claude"]); + expect(supported).toEqual(["claude", "pi"]); }); it("marks every first-class adapter as one-shot capable (they are all CLIs)", () => { diff --git a/src/supervisor/agents/pi/argv.ts b/src/supervisor/agents/pi/argv.ts new file mode 100644 index 00000000..f5e328c2 --- /dev/null +++ b/src/supervisor/agents/pi/argv.ts @@ -0,0 +1,56 @@ +import type { ThreadConfig } from "@/shared/contracts"; + +export const PI_THINKING_LEVELS = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; + +export function splitPiModelId( + model: string | undefined, +): { provider: string; modelId: string } | undefined { + const normalized = model?.trim(); + if (!normalized) return undefined; + const slash = normalized.indexOf("/"); + if (slash <= 0 || slash === normalized.length - 1) return undefined; + return { provider: normalized.slice(0, slash), modelId: normalized.slice(slash + 1) }; +} + +export function buildPiArgs(config: ThreadConfig, prompt: string, sessionId?: string): string[] { + const args = ["--approve"]; + if (sessionId) args.push("--session", sessionId); + if (config.model) args.push("--model", config.model); + if (config.effort && PI_THINKING_LEVELS.includes(config.effort as PiThinkingLevel)) { + args.push("--thinking", config.effort); + } + if (prompt.trim()) args.push(prompt); + return args; +} + +export function buildPiOneShotArgs( + config: Pick, + prompt: string, + options: { textOnly?: boolean } = {}, +) { + return [ + ...buildPiArgs(config as ThreadConfig, ""), + "--no-session", + ...(options.textOnly + ? [ + "--no-tools", + "--no-extensions", + "--no-skills", + "--no-prompt-templates", + "--no-context-files", + ] + : []), + "-p", + prompt, + ]; +} diff --git a/src/supervisor/agents/pi/cliProcess.test.ts b/src/supervisor/agents/pi/cliProcess.test.ts new file mode 100644 index 00000000..787aca78 --- /dev/null +++ b/src/supervisor/agents/pi/cliProcess.test.ts @@ -0,0 +1,245 @@ +import { execFileSync, spawn as spawnChild } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +// Poracode no longer bundles the Pi SDK; terminal + RPC modes drive the user's +// installed `pi`. These CLI-process checks run against that binary and skip when +// it is not present. +function resolveSystemPi(): string | undefined { + try { + const resolved = execFileSync("which", ["pi"], { encoding: "utf8" }).trim(); + return resolved.length > 0 ? resolved : undefined; + } catch { + return undefined; + } +} + +const SYSTEM_PI = resolveSystemPi(); + +interface ProcessResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function runPi( + executable: string, + args: readonly string[], + cwd: string, + env: NodeJS.ProcessEnv, + keepStdinOpen = false, +): Promise { + return new Promise((resolve, reject) => { + const child = spawnChild(executable, args, { cwd, env }); + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject( + new Error(`Pi CLI timed out for: ${args.join(" ")}\nstdout: ${stdout}\nstderr: ${stderr}`), + ); + }, 10_000); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + if (!keepStdinOpen) child.stdin.end(); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("close", (exitCode) => { + clearTimeout(timeout); + resolve({ stdout, stderr, exitCode: exitCode ?? -1 }); + }); + }); +} + +function tclWord(value: string): string { + return `{${value.replaceAll("\\", "\\\\").replaceAll("}", "\\}")}}`; +} + +function openAiChunk(content: string, finishReason: string | null = null): string { + return JSON.stringify({ + id: "chatcmpl-pi-cli-test", + object: "chat.completion.chunk", + created: 1, + model: "fixture-model", + choices: [{ index: 0, delta: content ? { content } : {}, finish_reason: finishReason }], + }); +} + +describe.runIf(process.platform === "darwin" && SYSTEM_PI !== undefined)("Pi CLI process", () => { + let root: string; + let agentDir: string; + let projectDir: string; + let server: Server; + let executable: string; + let env: NodeJS.ProcessEnv; + let requestBodies: string[]; + + beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), "poracode-pi-cli-")); + agentDir = join(root, "agent"); + projectDir = join(root, "project"); + executable = SYSTEM_PI as string; + requestBodies = []; + + server = createServer((request, response) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + requestBodies.push(Buffer.concat(chunks).toString("utf8")); + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + response.write(`data: ${openAiChunk("CLI_RESPONSE")}\n\n`); + response.write(`data: ${openAiChunk("", "stop")}\n\n`); + response.end("data: [DONE]\n\n"); + })().catch((error: unknown) => response.destroy(error as Error)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Fixture server did not bind."); + + mkdirSync(agentDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(join(projectDir, ".pi", "skills", "fixture-skill"), { recursive: true }); + writeFileSync(join(agentDir, "auth.json"), "{}\n"); + writeFileSync( + join(agentDir, "models.json"), + JSON.stringify({ + providers: { + fixture: { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + api: "openai-completions", + apiKey: "fixture-key", + models: [ + { + id: "fixture-model", + name: "Fixture Model", + reasoning: false, + contextWindow: 32_000, + maxTokens: 1_024, + }, + ], + }, + }, + }), + ); + writeFileSync( + join(projectDir, ".pi", "skills", "fixture-skill", "SKILL.md"), + "---\nname: fixture-skill\ndescription: CLI fixture skill\n---\nInclude CLI_SKILL_DELIVERED.\n", + ); + env = { + ...process.env, + CI: "1", + NO_COLOR: "1", + PI_CODING_AGENT_DIR: agentDir, + TERM: "xterm-256color", + }; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(root, { recursive: true, force: true }); + }); + + it("runs print, JSON, skill, resume, and interactive PTY workflows", async () => { + const common = [ + "--approve", + "--offline", + "--model", + "fixture/fixture-model", + "--thinking", + "off", + "--no-tools", + "--no-extensions", + "--no-prompt-templates", + ] as const; + + const printed = await runPi( + executable, + [...common, "--no-skills", "--no-session", "-p", "PRINT_CHECK"], + projectDir, + env, + ).catch((error: unknown) => { + throw new Error(`${String(error)}\nrequests: ${JSON.stringify(requestBodies)}`); + }); + expect(printed).toMatchObject({ exitCode: 0, stderr: "" }); + expect(printed.stdout).toContain("CLI_RESPONSE"); + expect(requestBodies.at(-1)).toContain("PRINT_CHECK"); + + const json = await runPi( + executable, + [...common, "--no-skills", "--no-session", "--mode", "json", "-p", "JSON_CHECK"], + projectDir, + env, + ); + expect(json.exitCode).toBe(0); + expect(json.stdout).toContain('"type":"message_update"'); + expect(json.stdout).toContain("CLI_RESPONSE"); + + const skill = await runPi( + executable, + [...common, "--no-session", "-p", "/skill:fixture-skill"], + projectDir, + env, + ); + expect(skill.exitCode).toBe(0); + expect(requestBodies.at(-1)).toContain("CLI_SKILL_DELIVERED"); + + const sessionId = "00000000-0000-4000-8000-000000000001"; + const firstTurn = await runPi( + executable, + [...common, "--no-skills", "--session-id", sessionId, "-p", "FIRST_TURN"], + projectDir, + env, + ); + expect(firstTurn.exitCode).toBe(0); + const resumed = await runPi( + executable, + [...common, "--no-skills", "--session", sessionId, "-p", "SECOND_TURN"], + projectDir, + env, + ); + expect(resumed.exitCode).toBe(0); + expect(requestBodies.at(-1)).toContain("FIRST_TURN"); + expect(requestBodies.at(-1)).toContain("SECOND_TURN"); + + const expectCommand = [executable, ...common, "--no-skills", "--no-session"] + .map(tclWord) + .join(" "); + const terminal = await runPi( + "/usr/bin/expect", + [ + "-c", + `set timeout 8 +spawn ${expectCommand} +expect { + -re {Press ctrl\\+o} {} + timeout { exit 123 } +} +send -- "PTY_CHECK\\r" +expect { + -re {CLI_RESPONSE} { send -- "\\004"; exp_continue } + eof {} + timeout { exit 124 } +}`, + ], + projectDir, + env, + true, + ); + if (terminal.exitCode !== 0) throw new Error(JSON.stringify(terminal)); + expect(requestBodies.at(-1)).toContain("PTY_CHECK"); + }, 60_000); +}); diff --git a/src/supervisor/agents/pi/detection.ts b/src/supervisor/agents/pi/detection.ts new file mode 100644 index 00000000..701ef316 --- /dev/null +++ b/src/supervisor/agents/pi/detection.ts @@ -0,0 +1,162 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { AgentCapability, AgentProviderMetadata, ProjectLocation } from "@/shared/contracts"; +import { + batchWslCommandsAsync, + envVarAuthProbe, + readAgentCommandOutput, + type AuthProbe, + type CapabilitiesProbeResult, + type DetectionSpec, +} from "../base"; + +const PI_AUTH_ENV_KEYS = [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "GROQ_API_KEY", + "MISTRAL_API_KEY", + "XAI_API_KEY", +] as const; + +function nativeAgentDir(): string { + return process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"); +} + +export function nativePiAuthPath(): string { + return join(nativeAgentDir(), "auth.json"); +} + +function nativeAuthProviders(): string[] { + try { + const parsed = JSON.parse(readFileSync(nativePiAuthPath(), "utf8")) as unknown; + return parsed && typeof parsed === "object" ? Object.keys(parsed) : []; + } catch { + return []; + } +} + +const piAuthFileProbe: AuthProbe = async (ctx) => { + if (ctx.location.kind === "wsl") { + const [result] = await batchWslCommandsAsync(ctx.location.distro, [ + 'test -s "${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/auth.json"', + ]); + return result?.ok ? "authenticated" : "missing"; + } + return existsSync(nativePiAuthPath()) && nativeAuthProviders().length > 0 + ? "authenticated" + : "missing"; +}; + +export const piDefaultCapabilities: AgentCapability = { + models: [], + efforts: [], + modelEfforts: {}, + modes: [], + approvalPolicies: [], + sandboxModes: [], + supportsResume: true, + supportsOneShot: true, + supportsTextOnlyOneShot: true, + supportsDirectInput: true, + liveInputMode: "terminal", + presentationMode: "terminal", + presentationModes: ["terminal", "gui"], + defaultApprovalPolicy: "never", + bypassPermissions: { approvalPolicy: "never" }, + mcpScope: { terminal: "none", gui: "launch" }, + settingDefs: [], +}; + +export interface PiCliModel { + id: string; + reasoning: boolean; +} + +export function parsePiModelList(stdout: string): PiCliModel[] { + return stdout + .split(/\r?\n/u) + .slice(1) + .flatMap((line) => { + const columns = line.trim().split(/\s{2,}/u); + const provider = columns[0]; + const model = columns[1]; + if (!provider || !model || columns.length < 6) return []; + return [{ id: `${provider}/${model}`, reasoning: columns[4]?.toLowerCase() === "yes" }]; + }); +} + +async function probePiCapabilities( + location: ProjectLocation, + executablePath: string, +): Promise { + // Native and WSL alike probe the installed CLI's model table — no bundled Pi + // SDK. The GUI structured session likewise drives the installed `pi --mode rpc`. + const output = await readAgentCommandOutput(location, executablePath, ["--list-models"], { + timeoutMs: 15_000, + }); + const models = output.ok ? parsePiModelList(output.stdout) : []; + const modelEfforts = Object.fromEntries( + models.map((model) => [model.id, model.reasoning ? [...PI_CLI_REASONING_LEVELS] : ["off"]]), + ); + const base: CapabilitiesProbeResult = { + ...piDefaultCapabilities, + models: models.map((model) => ({ id: model.id, label: model.id.split("/", 2)[1]! })), + efforts: [...new Set(Object.values(modelEfforts).flat())], + modelEfforts, + ...(models.length > 0 ? { authState: "authenticated" as const } : {}), + authMethods: [{ id: "pi-login", name: "Pi login", type: "terminal" }], + preferTerminalLogin: true, + }; + if (location.kind === "wsl") { + return { ...base, presentationModes: ["terminal"] }; + } + const providers = [ + ...new Set([...nativeAuthProviders(), ...models.map((model) => model.id.split("/", 1)[0]!)]), + ]; + const providerMetadata: AgentProviderMetadata | undefined = providers.length + ? { connectedProviders: providers.map((provider) => ({ id: provider, label: provider })) } + : undefined; + return { ...base, ...(providerMetadata ? { providerMetadata } : {}) }; +} + +export const piDetectionSpec: DetectionSpec = { + kind: "pi", + label: "Pi", + binary: "pi", + loginCommand: "pi", + capabilities: piDefaultCapabilities, + update: { + builtIn: { binary: "pi", args: ["update", "self"] }, + npm: "@earendil-works/pi-coding-agent", + installer: { + posix: { binary: "sh", args: ["-c", "curl -fsSL https://pi.dev/install.sh | sh"] }, + windows: { + binary: "powershell.exe", + args: ["-NoProfile", "-Command", "irm https://pi.dev/install.ps1 | iex"], + }, + }, + }, + authProbes: [envVarAuthProbe([...PI_AUTH_ENV_KEYS]), piAuthFileProbe], + async capabilitiesProbe(ctx) { + if (!ctx.executablePath) return undefined; + return probePiCapabilities(ctx.location, ctx.executablePath); + }, +}; + +const PI_CLI_REASONING_LEVELS = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +export function piAgentHomePath(): string { + return process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"); +} diff --git a/src/supervisor/agents/pi/index.ts b/src/supervisor/agents/pi/index.ts new file mode 100644 index 00000000..6060c73d --- /dev/null +++ b/src/supervisor/agents/pi/index.ts @@ -0,0 +1,132 @@ +import type { PromptSegment } from "@/shared/contracts"; +import { inlinePromptSegmentText } from "@/shared/promptContent"; +import { detectAgentInstall, type AgentAdapter } from "../base"; +import { buildPiArgs, buildPiOneShotArgs } from "./argv"; +import { piDefaultCapabilities, piDetectionSpec } from "./detection"; +import { PiRpcSession } from "./rpcSession"; +import { + discoverPiSessionRef, + snapshotPiPreSpawnSessions, + watchPiSessionRef, +} from "./sessionFiles"; +import { detectPiTerminalStatus } from "./terminal"; + +export function createPiAdapter(): AgentAdapter { + let capabilities = piDefaultCapabilities; + + return { + kind: piDetectionSpec.kind, + label: piDetectionSpec.label, + binary: piDetectionSpec.binary, + ...(piDetectionSpec.update ? { update: piDetectionSpec.update } : {}), + skillSupport: { + roots: [ + { + id: "pi", + label: piDetectionSpec.label, + globalPath: ".pi/agent/skills", + projectPath: ".pi/skills", + globalOverride: { env: "PI_CODING_AGENT_DIR", path: "skills" }, + }, + { + id: "agents", + label: "Shared agent skills", + globalPath: ".agents/skills", + projectPath: ".agents/skills", + }, + ], + invocation: "skill", + precedence: { + global: ["pi", "agents"], + project: ["pi", "agents"], + }, + }, + get capabilities() { + return capabilities; + }, + + async detectInstall(ctx) { + const status = await detectAgentInstall(ctx, piDetectionSpec); + capabilities = status.capabilities; + return status; + }, + + buildLaunchArgv(location, config, prompt) { + void snapshotPiPreSpawnSessions(location); + return { binary: "pi", args: buildPiArgs(config, prompt) }; + }, + + buildResumeArgv(_location, config, prompt, sessionRef) { + return { + binary: "pi", + args: buildPiArgs(config, prompt, sessionRef.providerSessionId), + }; + }, + + async createStructuredSession(input) { + if (input.presentationMode !== "gui") return undefined; + return PiRpcSession.create(input); + }, + + createInitialSessionRef() { + return undefined; + }, + initialSessionRefDiscoveryDelayMs: 1_000, + discoverSessionRef: discoverPiSessionRef, + watchSessionRef: watchPiSessionRef, + + buildDirectInput(prompt) { + return [prompt, "@wait:150", "\r"]; + }, + + formatPromptSegments(segments: PromptSegment[]) { + const attachments = segments.filter((segment) => segment.kind === "attachment"); + const content = segments + .filter((segment) => segment.kind !== "attachment") + .map(inlinePromptSegmentText) + .join(""); + const paths = attachments.map((segment) => `@${segment.path}`).join(" "); + return paths ? `${content}\n\n${paths}` : content; + }, + + isReadyForInitialPrompt(text) { + return /\bpi\b|\/\s+for\s+commands|ctrl-p\s+to\s+switch/i.test(text); + }, + detectTerminalStatus: detectPiTerminalStatus, + + buildOneShotCommand(model, effort, prompt) { + if (!prompt) return undefined; + return { + command: "pi", + args: buildPiOneShotArgs({ model, ...(effort ? { effort } : {}) }, prompt, { + textOnly: true, + }), + stdin: "", + }; + }, + + buildTextOnlyOneShotCommand(model, effort, prompt) { + if (!prompt) return undefined; + return { + command: "pi", + args: buildPiOneShotArgs({ model, ...(effort ? { effort } : {}) }, prompt, { + textOnly: true, + }), + stdin: "", + }; + }, + + buildSubagentOneShotCommand(input) { + return { + command: "pi", + args: buildPiOneShotArgs( + { model: input.model, ...(input.effort ? { effort: input.effort } : {}) }, + input.prompt, + ), + stdin: "", + }; + }, + }; +} + +export { piDefaultCapabilities, piDetectionSpec } from "./detection"; diff --git a/src/supervisor/agents/pi/mcpExtension.test.ts b/src/supervisor/agents/pi/mcpExtension.test.ts new file mode 100644 index 00000000..265f9a21 --- /dev/null +++ b/src/supervisor/agents/pi/mcpExtension.test.ts @@ -0,0 +1,38 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { dirname } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { writePiMcpExtension } from "./mcpExtension"; + +describe("writePiMcpExtension", () => { + const created: string[] = []; + afterEach(() => { + for (const path of created) rmSync(dirname(path), { recursive: true, force: true }); + created.length = 0; + }); + + it("generates a self-contained ESM extension embedding the server config", async () => { + const extensionPath = await writePiMcpExtension([ + { + id: "srv", + name: "my-server", + timeoutMs: 12_345, + disabledTools: ["secret"], + transport: { type: "stdio", command: "node", args: ["server.mjs"], env: { KEY: "val" } }, + }, + ]); + created.push(extensionPath); + expect(existsSync(extensionPath)).toBe(true); + const source = readFileSync(extensionPath, "utf8"); + // Server config is embedded for the bridge to consume. + expect(source).toContain('"my-server"'); + expect(source).toContain("12345"); + expect(source).toContain('"secret"'); + expect(source).toContain("server.mjs"); + // Self-contained: pi ships no MCP SDK, so the bridge must not import one. + expect(source).not.toContain("@modelcontextprotocol"); + // The generated module parses and loads without running the bridge. + const mod = await import(pathToFileURL(extensionPath).href); + expect(typeof mod.default).toBe("function"); + }); +}); diff --git a/src/supervisor/agents/pi/mcpExtension.ts b/src/supervisor/agents/pi/mcpExtension.ts new file mode 100644 index 00000000..eb150ba7 --- /dev/null +++ b/src/supervisor/agents/pi/mcpExtension.ts @@ -0,0 +1,220 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ResolvedMcpServer } from "@/shared/contracts"; + +interface BridgeServer { + name: string; + timeoutMs: number; + disabledTools?: string[]; + transport: ResolvedMcpServer["transport"]; +} + +/** + * Pi's RPC mode has no native MCP support and the installed `pi` CLI does not + * ship `@modelcontextprotocol/sdk`, so Poracode's resolved MCP servers are + * bridged by generating a self-contained pi extension. The extension runs + * inside the pi subprocess, speaks MCP directly (stdio via child_process, + * HTTP/SSE via fetch — no external imports, so it also works once Poracode's + * own node_modules is packed into an asar), and registers each server tool as + * a Pi custom tool named `mcp____`. + */ +export async function writePiMcpExtension(servers: readonly ResolvedMcpServer[]): Promise { + const bridgeServers: BridgeServer[] = servers.map((server) => ({ + name: server.name, + timeoutMs: server.timeoutMs, + ...(server.disabledTools?.length ? { disabledTools: server.disabledTools } : {}), + transport: server.transport, + })); + const dir = mkdtempSync(join(tmpdir(), "poracode-pi-mcp-")); + const extensionPath = join(dir, "poracode-mcp-bridge.mjs"); + writeFileSync(extensionPath, buildExtensionSource(bridgeServers), "utf8"); + return extensionPath; +} + +function buildExtensionSource(servers: BridgeServer[]): string { + return `// Generated by Poracode — bridges Poracode MCP servers into Pi as custom tools. +import { spawn } from "node:child_process"; + +const SERVERS = ${JSON.stringify(servers)}; + +function sanitize(name) { + return String(name).replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function withTimeout(promise, ms, label) { + if (!ms) return promise; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(label + " timed out after " + ms + "ms")), ms); + promise.then( + (value) => { clearTimeout(timer); resolve(value); }, + (error) => { clearTimeout(timer); reject(error); }, + ); + }); +} + +class StdioClient { + constructor(server) { + const t = server.transport; + this.child = spawn(t.command, t.args ?? [], { + ...(t.cwd ? { cwd: t.cwd } : {}), + env: { ...process.env, ...(t.env ?? {}) }, + stdio: ["pipe", "pipe", "pipe"], + }); + this.child.on("error", () => undefined); + this.child.stderr?.on("data", () => undefined); + this.buffer = ""; + this.pending = new Map(); + this.seq = 0; + this.child.stdout.on("data", (chunk) => this.onData(String(chunk))); + } + onData(chunk) { + this.buffer += chunk; + let index; + while ((index = this.buffer.indexOf("\\n")) >= 0) { + const line = this.buffer.slice(0, index).trim(); + this.buffer = this.buffer.slice(index + 1); + if (!line) continue; + let message; + try { message = JSON.parse(line); } catch { continue; } + if (message.id != null && this.pending.has(message.id)) { + const pending = this.pending.get(message.id); + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(message.error.message || "MCP error")); + else pending.resolve(message.result); + } + } + } + write(message) { + this.child.stdin.write(JSON.stringify(message) + "\\n"); + } + request(method, params, timeoutMs) { + const id = ++this.seq; + const promise = new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.write({ jsonrpc: "2.0", id, method, ...(params !== undefined ? { params } : {}) }); + }); + return withTimeout(promise, timeoutMs, method); + } + notify(method, params) { + this.write({ jsonrpc: "2.0", method, ...(params !== undefined ? { params } : {}) }); + } + close() { + try { this.child.kill(); } catch { /* already gone */ } + } +} + +class HttpClient { + constructor(server) { + const t = server.transport; + this.url = t.url; + this.headers = t.headers ?? {}; + this.sessionId = undefined; + this.seq = 0; + } + async request(method, params, timeoutMs) { + const id = ++this.seq; + const response = await fetch(this.url, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...this.headers, + ...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}), + }, + body: JSON.stringify({ jsonrpc: "2.0", id, method, ...(params !== undefined ? { params } : {}) }), + ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}), + }); + const sessionId = response.headers.get("mcp-session-id"); + if (sessionId) this.sessionId = sessionId; + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("text/event-stream")) { + const text = await response.text(); + for (const line of text.split(/\\r?\\n/)) { + if (!line.startsWith("data:")) continue; + let message; + try { message = JSON.parse(line.slice(5).trim()); } catch { continue; } + if (message.id === id) { + if (message.error) throw new Error(message.error.message || "MCP error"); + return message.result; + } + } + throw new Error("MCP server returned no matching response for " + method); + } + const message = await response.json(); + if (message.error) throw new Error(message.error.message || "MCP error"); + return message.result; + } + notify() { /* stateless HTTP needs no notifications */ } + close() {} +} + +async function connect(server) { + const client = server.transport.type === "stdio" ? new StdioClient(server) : new HttpClient(server); + await client.request( + "initialize", + { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "poracode-pi-mcp", version: "1.0.0" } }, + server.timeoutMs, + ); + client.notify("notifications/initialized", {}); + return client; +} + +async function listTools(client, timeoutMs) { + const tools = []; + let cursor; + do { + const result = await client.request("tools/list", cursor ? { cursor } : {}, timeoutMs); + for (const tool of result?.tools ?? []) tools.push(tool); + cursor = result?.nextCursor; + } while (cursor); + return tools; +} + +function toPiResult(result) { + const content = []; + for (const entry of result?.content ?? []) { + if (entry?.type === "text" && typeof entry.text === "string") { + content.push({ type: "text", text: entry.text }); + } else if (entry?.type === "image" && typeof entry.data === "string" && typeof entry.mimeType === "string") { + content.push({ type: "image", data: entry.data, mimeType: entry.mimeType }); + } + } + if (result?.isError === true) { + const message = content.filter((entry) => entry.type === "text").map((entry) => entry.text).join("\\n"); + throw new Error(message || "MCP tool failed"); + } + return { + content: content.length > 0 ? content : [{ type: "text", text: JSON.stringify(result?.structuredContent ?? {}) }], + details: result, + }; +} + +export default async function poracodeMcpBridge(pi) { + for (const server of SERVERS) { + let client; + try { + client = await connect(server); + const disabled = new Set(server.disabledTools ?? []); + const tools = await listTools(client, server.timeoutMs); + for (const tool of tools) { + if (disabled.has(tool.name)) continue; + const name = "mcp__" + sanitize(server.name) + "__" + sanitize(tool.name); + pi.registerTool({ + name, + label: server.name + ": " + (tool.title ?? tool.name), + description: tool.description ?? ("Run " + tool.name + " on the " + server.name + " MCP server."), + parameters: tool.inputSchema ?? { type: "object", properties: {} }, + execute: async (_toolCallId, params) => { + const result = await client.request("tools/call", { name: tool.name, arguments: params ?? {} }, server.timeoutMs); + return toPiResult(result); + }, + }); + } + } catch (error) { + console.error("[poracode-mcp] unable to bridge MCP server " + server.name + ": " + (error?.message ?? error)); + } + } +} +`; +} diff --git a/src/supervisor/agents/pi/pi.test.ts b/src/supervisor/agents/pi/pi.test.ts new file mode 100644 index 00000000..75716af8 --- /dev/null +++ b/src/supervisor/agents/pi/pi.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import type { ProjectLocation, ThreadConfig } from "@/shared/contracts"; +import { createKnownSessionRef } from "../base"; +import { buildPiArgs, buildPiOneShotArgs, splitPiModelId } from "./argv"; +import { parsePiModelList, piDefaultCapabilities, piDetectionSpec } from "./detection"; +import { createPiAdapter } from "./index"; + +const location = { kind: "posix", path: "/tmp/pi-project" } as ProjectLocation; + +describe("Pi provider metadata", () => { + const adapter = createPiAdapter(); + + it("declares terminal and native SDK chat without inventing plan or permission modes", () => { + expect(adapter).toMatchObject({ kind: "pi", label: "Pi", binary: "pi" }); + expect(adapter.capabilities).toMatchObject({ + presentationMode: "terminal", + presentationModes: ["terminal", "gui"], + modes: [], + approvalPolicies: [], + supportsResume: true, + supportsOneShot: true, + mcpScope: { terminal: "none", gui: "launch" }, + }); + expect(typeof adapter.createStructuredSession).toBe("function"); + }); + + it("uses Pi and shared Agent Skills roots with Pi's native invocation", () => { + expect(adapter.skillSupport).toEqual({ + roots: [ + { + id: "pi", + label: "Pi", + globalPath: ".pi/agent/skills", + projectPath: ".pi/skills", + globalOverride: { env: "PI_CODING_AGENT_DIR", path: "skills" }, + }, + { + id: "agents", + label: "Shared agent skills", + globalPath: ".agents/skills", + projectPath: ".agents/skills", + }, + ], + invocation: "skill", + precedence: { global: ["pi", "agents"], project: ["pi", "agents"] }, + }); + }); + + it("wires the official self updater and package fallback", () => { + expect(adapter.update).toMatchObject({ + builtIn: { binary: "pi", args: ["update", "self"] }, + npm: "@earendil-works/pi-coding-agent", + }); + expect(piDetectionSpec.loginCommand).toBe("pi"); + }); +}); + +describe("Pi CLI argv", () => { + it("passes model and every supported thinking effort to interactive Pi", () => { + expect( + buildPiArgs( + { model: "anthropic/claude-sonnet-4-5", effort: "max" } as ThreadConfig, + "inspect this", + ), + ).toEqual([ + "--approve", + "--model", + "anthropic/claude-sonnet-4-5", + "--thinking", + "max", + "inspect this", + ]); + }); + + it("resumes the exact Pi session id", () => { + const adapter = createPiAdapter(); + expect( + adapter.buildResumeArgv( + location, + { model: "openai/gpt-5-mini", effort: "low" } as ThreadConfig, + "continue", + createKnownSessionRef("0198-session"), + ).args, + ).toEqual([ + "--approve", + "--session", + "0198-session", + "--model", + "openai/gpt-5-mini", + "--thinking", + "low", + "continue", + ]); + }); + + it("builds an ephemeral print-mode command for utility and subagent runs", () => { + expect( + buildPiOneShotArgs({ model: "google/gemini-2.5-flash", effort: "minimal" }, "reply OK", { + textOnly: true, + }), + ).toEqual([ + "--approve", + "--model", + "google/gemini-2.5-flash", + "--thinking", + "minimal", + "--no-session", + "--no-tools", + "--no-extensions", + "--no-skills", + "--no-prompt-templates", + "--no-context-files", + "-p", + "reply OK", + ]); + }); + + it("formats attachments with Pi's documented @path syntax", () => { + const formatted = createPiAdapter().formatPromptSegments?.([ + { kind: "text", content: "Review these" }, + { kind: "attachment", path: "/tmp/a.png", mimeType: "image/png" }, + { kind: "attachment", path: "/tmp/spec.md" }, + ]); + expect(formatted).toBe("Review these\n\n@/tmp/a.png @/tmp/spec.md"); + }); + + it("splits fully qualified model ids without truncating nested ids", () => { + expect(splitPiModelId("openrouter/anthropic/claude-sonnet")).toEqual({ + provider: "openrouter", + modelId: "anthropic/claude-sonnet", + }); + expect(splitPiModelId("sonnet")).toBeUndefined(); + }); +}); + +describe("Pi terminal behavior", () => { + const adapter = createPiAdapter(); + + it("submits direct input after Pi's paste guard window", () => { + expect(adapter.buildDirectInput?.("hello")).toEqual(["hello", "@wait:150", "\r"]); + }); + + it("recognizes Pi working and idle output", () => { + expect(adapter.detectTerminalStatus?.("Thinking… esc to abort")?.status).toBe("working"); + expect(adapter.detectTerminalStatus?.("12,400 tokens | $0.02")?.status).toBe("idle"); + }); +}); + +describe("Pi capability defaults", () => { + it("keeps model and effort discovery dynamic", () => { + expect(piDefaultCapabilities.models).toEqual([]); + expect(piDefaultCapabilities.efforts).toEqual([]); + expect(piDefaultCapabilities.modelEfforts).toEqual({}); + }); + + it("parses Pi's native model table for WSL terminal discovery", () => { + expect( + parsePiModelList(`provider model context max-out thinking images +anthropic claude-sonnet-4 200K 64K yes yes +openai gpt-4.1-mini 1M 32K no yes`), + ).toEqual([ + { id: "anthropic/claude-sonnet-4", reasoning: true }, + { id: "openai/gpt-4.1-mini", reasoning: false }, + ]); + }); +}); diff --git a/src/supervisor/agents/pi/rpcClient.ts b/src/supervisor/agents/pi/rpcClient.ts new file mode 100644 index 00000000..6351b6bd --- /dev/null +++ b/src/supervisor/agents/pi/rpcClient.ts @@ -0,0 +1,166 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { terminateChildProcessTree } from "@/shared/processTree"; + +export interface PiRpcResponse { + success: boolean; + data?: unknown; + error?: string; +} + +export type PiRpcEvent = Record; +export type PiRpcEventHandler = (event: PiRpcEvent) => void; + +export interface PiRpcSpawnSpec { + command: string; + args: string[]; + cwd?: string; + env?: Record; +} + +interface PendingRequest { + resolve(response: PiRpcResponse): void; + reject(error: Error): void; +} + +/** + * Minimal JSONL client for `pi --mode rpc`. Pi speaks newline-delimited JSON + * over stdio: commands written to stdin (optionally with an `id`), responses + * (`{ type: "response", id, success, data? }`) and asynchronous events streamed + * on stdout. This client owns the subprocess lifecycle and request/response + * correlation; the session layer maps events onto canonical runtime events. + */ +export class PiRpcClient { + readonly spawnReady: Promise; + private readonly child: ChildProcess; + private readonly exitPromise: Promise; + private readonly pending = new Map(); + private readonly eventHandlers = new Set(); + private readonly exitHandlers = new Set<() => void>(); + private readonly stderrTail: string[] = []; + private buffer = ""; + private sequence = 0; + private closed = false; + + private constructor(child: ChildProcess) { + this.child = child; + this.spawnReady = new Promise((resolve, reject) => { + child.on("error", (error) => { + reject(new Error(`Pi RPC agent failed to start: ${error.message}`)); + }); + child.on("spawn", () => resolve()); + }); + this.exitPromise = new Promise((resolve) => { + child.on("exit", () => resolve()); + }); + void this.exitPromise.then(() => this.handleClose()); + child.stdout?.on("data", (chunk: Buffer | string) => this.onData(String(chunk))); + child.stderr?.on("data", (chunk: Buffer | string) => { + this.stderrTail.push(String(chunk)); + if (this.stderrTail.length > 20) this.stderrTail.shift(); + }); + } + + static spawn(spec: PiRpcSpawnSpec): PiRpcClient { + const child = spawn(spec.command, spec.args, { + ...(spec.cwd ? { cwd: spec.cwd } : {}), + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, TERM: "xterm-256color", ...(spec.env ?? {}) }, + shell: false, + windowsHide: true, + }); + return new PiRpcClient(child); + } + + get isClosed(): boolean { + return this.closed; + } + + get stderr(): string { + return this.stderrTail.join("").trim(); + } + + onEvent(handler: PiRpcEventHandler): () => void { + this.eventHandlers.add(handler); + return () => { + this.eventHandlers.delete(handler); + }; + } + + onExit(handler: () => void): () => void { + if (this.closed) { + handler(); + return () => undefined; + } + this.exitHandlers.add(handler); + return () => { + this.exitHandlers.delete(handler); + }; + } + + /** Send a command and await its correlated response. */ + async request(command: string, params: Record = {}): Promise { + if (this.closed) throw new Error("Pi RPC session is closed."); + await this.spawnReady; + const id = `pi-rpc-${++this.sequence}`; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.write({ id, type: command, ...params }); + }); + } + + /** Send a message that does not expect a correlated response. */ + notify(message: Record): void { + if (this.closed) return; + this.write(message); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + terminateChildProcessTree(this.child); + await Promise.race([this.exitPromise, new Promise((resolve) => setTimeout(resolve, 2_000))]); + } + + private write(message: Record): void { + this.child.stdin?.write(`${JSON.stringify(message)}\n`); + } + + private onData(chunk: string): void { + this.buffer += chunk; + let newline: number; + while ((newline = this.buffer.indexOf("\n")) >= 0) { + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + let message: PiRpcEvent; + try { + message = JSON.parse(line) as PiRpcEvent; + } catch { + continue; + } + if (message.type === "response" && typeof message.id === "string") { + const pending = this.pending.get(message.id); + if (pending) { + this.pending.delete(message.id); + pending.resolve({ + success: message.success === true, + ...(message.data !== undefined ? { data: message.data } : {}), + ...(message.error !== undefined ? { error: String(message.error) } : {}), + }); + } + continue; + } + for (const handler of this.eventHandlers) handler(message); + } + } + + private handleClose(): void { + this.closed = true; + for (const pending of this.pending.values()) { + pending.reject(new Error("Pi RPC session closed.")); + } + this.pending.clear(); + for (const handler of this.exitHandlers) handler(); + this.exitHandlers.clear(); + } +} diff --git a/src/supervisor/agents/pi/rpcSession.test.ts b/src/supervisor/agents/pi/rpcSession.test.ts new file mode 100644 index 00000000..fcf4adec --- /dev/null +++ b/src/supervisor/agents/pi/rpcSession.test.ts @@ -0,0 +1,330 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { RuntimeEvent } from "@/shared/contracts"; +import type { StructuredSessionUpdate } from "../base"; +import { PiRpcSession } from "./rpcSession"; + +// A minimal stand-in for `pi --mode rpc` so the session's protocol handling is +// exercised deterministically without a live provider or the (removed) SDK. +const MOCK_PI_SOURCE = `#!/usr/bin/env node +import { createInterface } from "node:readline"; +const rl = createInterface({ input: process.stdin }); +function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } +rl.on("line", (line) => { + let req; + try { req = JSON.parse(line); } catch { return; } + const { id, type } = req; + if (type === "prompt") { + send({ type: "response", id, command: "prompt", success: true }); + const text = req.message || ""; + if (text.includes("GOAL_PLUGIN") && !text.includes("CODEX_GOAL_PLUGIN")) { + const goal = { id: "pi-goal-1", text: "Finish the goal smoke", status: "active", startedAt: 1700000000000, updatedAt: 1700000000000, iteration: 0, tokenBudget: 10000, tokensUsed: 0, timeUsedSeconds: 0 }; + send({ type: "entry_appended", entry: { type: "custom", customType: "goal-state", data: { goal } } }); + send({ type: "entry_appended", entry: { type: "custom", customType: "goal-state", data: { goal: { ...goal, status: "paused", updatedAt: 1700000001000, iteration: 1, tokensUsed: 4000, timeUsedSeconds: 1 } } } }); + send({ type: "entry_appended", entry: { type: "custom", customType: "goal-state", data: { goal: { ...goal, status: "complete", updatedAt: 1700000002000, iteration: 1, tokensUsed: 5000, timeUsedSeconds: 2 } } } }); + send({ type: "entry_appended", entry: { type: "custom", customType: "goal-state", data: { goal: null } } }); + send({ type: "agent_start" }); + send({ type: "agent_settled" }); + return; + } + if (text.includes("GENERIC_PLUGIN")) { + send({ type: "extension_ui_request", id: "status-1", method: "setStatus", statusKey: "plugin-progress", statusText: "halfway" }); + send({ type: "entry_appended", entry: { type: "custom", customType: "plugin-progress", data: { current: 1, total: 2 } } }); + send({ type: "agent_start" }); + send({ type: "agent_settled" }); + return; + } + if (text.includes("CODEX_GOAL_PLUGIN")) { + const goal = { goalId: "codex-goal-1", objective: "Finish the Codex-style goal", status: "active", tokenBudget: 10000, usage: { tokensUsed: 0, activeSeconds: 0 }, createdAt: 1700000000000, updatedAt: 1700000000000 }; + send({ type: "entry_appended", entry: { type: "custom", customType: "pi-codex-goal", data: { version: 1, kind: "set", source: "tool", goal, at: 1700000000000 } } }); + send({ type: "entry_appended", entry: { type: "custom", customType: "pi-codex-goal", data: { version: 1, kind: "usage", source: "runtime", goalId: "codex-goal-1", status: "budgetLimited", usage: { tokensUsed: 10000, activeSeconds: 7 }, updatedAt: 1700000001000, at: 1700000001000 } } }); + send({ type: "entry_appended", entry: { type: "custom", customType: "pi-codex-goal", data: { version: 1, kind: "set", source: "tool", goal: { ...goal, status: "complete", usage: { tokensUsed: 12000, activeSeconds: 9 }, updatedAt: 1700000002000 }, at: 1700000002000 } } }); + send({ type: "entry_appended", entry: { type: "custom", customType: "pi-codex-goal", data: { version: 1, kind: "clear", source: "tool", clearedGoalId: "codex-goal-1", at: 1700000003000 } } }); + send({ type: "agent_start" }); + send({ type: "agent_settled" }); + return; + } + if (text.includes("DIALOG")) { + send({ type: "extension_ui_request", id: "dlg-1", method: "select", title: "Pick one", options: ["alpha", "beta"] }); + return; + } + if (text.includes("FAIL")) { + send({ type: "agent_start" }); + send({ type: "message_update", assistantMessageEvent: { type: "error", error: { errorMessage: "MOCK_PROVIDER_ERROR" } } }); + send({ type: "agent_settled" }); + return; + } + send({ type: "agent_start" }); + send({ type: "turn_start" }); + send({ type: "message_start" }); + send({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "MOCK_" } }); + send({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "RESPONSE" } }); + send({ type: "message_end" }); + send({ type: "turn_end" }); + send({ type: "agent_end" }); + send({ type: "agent_settled" }); + return; + } + if (type === "extension_ui_response") { + send({ type: "agent_start" }); + send({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "DIALOG_DONE:" + (req.value ?? "cancelled") } }); + send({ type: "agent_settled" }); + return; + } + if (type === "get_state") { + send({ type: "response", id, command: "get_state", success: true, data: { sessionId: "mock-session-1", isStreaming: false } }); + return; + } + if (type === "get_session_stats") { + send({ type: "response", id, command: "get_session_stats", success: true, data: { sessionId: "mock-session-1", contextUsage: { tokens: 100, contextWindow: 1000, percent: 10 } } }); + return; + } + if (type === "get_commands") { + send({ type: "response", id, command: "get_commands", success: true, data: { commands: [{ name: "mock-command", description: "A mock command" }] } }); + return; + } + if (type === "abort" || type === "set_model" || type === "set_thinking_level" || type === "steer") { + send({ type: "response", id, command: type, success: true }); + return; + } +}); +`; + +function waitFor( + events: RuntimeEvent[], + predicate: (event: RuntimeEvent) => boolean, +): Promise { + return new Promise((resolve) => { + const existing = events.find(predicate); + if (existing) return resolve(existing); + const interval = setInterval(() => { + const found = events.find(predicate); + if (found) { + clearInterval(interval); + resolve(found); + } + }, 5); + }); +} + +describe("PiRpcSession (mock pi --mode rpc)", () => { + let projectDir: string; + let mockBinary: string; + + beforeAll(() => { + projectDir = mkdtempSync(join(tmpdir(), "poracode-pi-rpc-mock-")); + mockBinary = join(projectDir, "mock-pi.mjs"); + writeFileSync(mockBinary, MOCK_PI_SOURCE, "utf8"); + chmodSync(mockBinary, 0o755); + }); + afterAll(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + function makeSession() { + const events: RuntimeEvent[] = []; + const updates: StructuredSessionUpdate[] = []; + return { events, updates }; + } + + async function createSession() { + const { events, updates } = makeSession(); + const session = await PiRpcSession.create( + { + threadId: "thread-mock", + projectLocation: { kind: "posix", path: projectDir }, + config: { model: "mock/model", effort: "off" }, + presentationMode: "gui", + }, + { binary: mockBinary }, + ); + session.setListener({ + onClose() {}, + onError() {}, + onUpdate(update) { + updates.push(update); + }, + onRuntimeEvent(event) { + events.push(event); + }, + }); + return { session, events, updates }; + } + + it("streams a turn into canonical events and publishes session ref + context", async () => { + const { session, events, updates } = await createSession(); + await session.startTurn?.("hello", { model: "mock/model", effort: "off" }); + + const text = events + .filter( + (e): e is Extract => + e.type === "content.delta" && e.stream === "assistant_text", + ) + .map((e) => e.delta) + .join(""); + expect(text).toBe("MOCK_RESPONSE"); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "session.started" }), + expect.objectContaining({ type: "turn.started" }), + expect.objectContaining({ type: "item.started", itemType: "assistant_message" }), + expect.objectContaining({ type: "turn.completed", state: "completed" }), + ]), + ); + // Context usage + session ref publish asynchronously after the turn settles. + await waitFor(events, (e) => e.type === "context.updated"); + expect(events).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "context.updated" })]), + ); + await waitFor(events, () => + updates.some((u) => u.sessionRef?.providerSessionId === "mock-session-1"), + ); + expect(updates.every((update) => update.sessionRef?.providerSessionId !== "")).toBe(true); + expect(updates.at(-1)?.sessionRef?.providerSessionId).toBe("mock-session-1"); + await session.dispose(); + }); + + it("surfaces a provider error as a failed turn", async () => { + const { session, events } = await createSession(); + await session.startTurn?.("FAIL please", { model: "mock/model", effort: "off" }); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "error", message: "MOCK_PROVIDER_ERROR" }), + expect.objectContaining({ type: "turn.completed", state: "failed" }), + ]), + ); + await session.dispose(); + }); + + it("maps persisted pi-goal lifecycle entries into a canonical goal item", async () => { + const { session, events } = await createSession(); + await session.startTurn?.("GOAL_PLUGIN", { model: "mock/model", effort: "off" }); + + const goalEvents = events.filter( + (event): event is Extract => + (event.type === "item.started" && event.itemType === "goal") || + event.type === "item.updated", + ); + expect(goalEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "item.started", + itemType: "goal", + payload: expect.objectContaining({ + action: "set", + objective: "Finish the goal smoke", + status: "active", + tokenBudget: 10000, + }), + }), + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ status: "paused", tokensUsed: 4000 }), + }), + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ status: "complete", tokensUsed: 5000 }), + }), + ]), + ); + expect( + goalEvents.some( + (event) => + event.type === "item.updated" && + (event.payload as { action?: string }).action === "cleared", + ), + ).toBe(false); + await session.dispose(); + }); + + it("maps pi-codex-goal session entries into the native goal lifecycle", async () => { + const { session, events } = await createSession(); + await session.startTurn?.("CODEX_GOAL_PLUGIN", { model: "mock/model", effort: "off" }); + + const goalEvents = events.filter( + (event): event is Extract => + (event.type === "item.started" && event.itemType === "goal") || + event.type === "item.updated", + ); + expect(goalEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "item.started", + itemType: "goal", + payload: expect.objectContaining({ + objective: "Finish the Codex-style goal", + status: "active", + }), + }), + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ status: "budget_limited", tokensUsed: 10000 }), + }), + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ status: "complete", tokensUsed: 12000 }), + }), + ]), + ); + expect( + goalEvents.some( + (event) => + event.type === "item.updated" && + (event.payload as { action?: string }).action === "cleared", + ), + ).toBe(false); + await session.dispose(); + }); + + it("preserves unknown Pi plugin status and custom entries as generic activity", async () => { + const { session, events } = await createSession(); + await session.startTurn?.("GENERIC_PLUGIN", { model: "mock/model", effort: "off" }); + + const activities = events.filter( + (event): event is Extract => + event.type === "item.started" && event.itemType === "dynamic_tool_call", + ); + expect(activities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: expect.objectContaining({ name: "plugin-progress", result: "halfway" }), + }), + expect.objectContaining({ + payload: expect.objectContaining({ + name: "plugin-progress", + result: { current: 1, total: 2 }, + }), + }), + ]), + ); + await session.dispose(); + }); + + it("maps an extension UI dialog to a server request and resolves it", async () => { + const { session, events } = await createSession(); + const turn = session.startTurn?.("DIALOG", { model: "mock/model", effort: "off" }); + const request = (await waitFor(events, (e) => e.type === "request.opened")) as Extract< + RuntimeEvent, + { type: "request.opened" } + >; + expect(request.payload.summary).toBe("Pick one"); + await session.resolveServerRequest?.(request.requestId, { optionId: "alpha" }); + await turn; + const text = events + .filter( + (e): e is Extract => + e.type === "content.delta" && e.stream === "assistant_text", + ) + .map((e) => e.delta) + .join(""); + expect(text).toBe("DIALOG_DONE:alpha"); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "request.resolved", outcome: "answered" }), + ]), + ); + await session.dispose(); + }); +}); diff --git a/src/supervisor/agents/pi/rpcSession.ts b/src/supervisor/agents/pi/rpcSession.ts new file mode 100644 index 00000000..8667dc87 --- /dev/null +++ b/src/supervisor/agents/pi/rpcSession.ts @@ -0,0 +1,1020 @@ +import type { + AgentSlashCommand, + PromptSegment, + RuntimeEvent, + ThreadConfig, + ThreadServerRequestId, +} from "@/shared/contracts"; +import { + createKnownSessionRef, + type CreateStructuredSessionInput, + type StartTurnOptions, + type StructuredSessionHandle, + type StructuredSessionListener, +} from "../base"; +import { + goalPayloadFromProviderState, + startGoalItemEvents, + updateGoalItemEvents, + type ProviderGoalState, +} from "../goalRuntime"; +import { resolveAgentBinaryPath } from "../binaryResolver"; +import { buildQuestionAnswerEvents } from "../questionAnswerEvents"; +import { splitPiModelId, type PiThinkingLevel, PI_THINKING_LEVELS } from "./argv"; +import { writePiMcpExtension } from "./mcpExtension"; +import { PiRpcClient, type PiRpcEvent } from "./rpcClient"; + +const NOOP_LISTENER: StructuredSessionListener = { + onClose() {}, + onError() {}, + onUpdate() {}, +}; + +type PiDialogKind = "select" | "confirm" | "input" | "editor"; + +interface PendingDialog { + kind: PiDialogKind; + title: string; + message?: string; + options?: string[]; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function recordOf(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +function readResponseValue(response: unknown): unknown { + const record = recordOf(response); + if (!record) return response; + if (record.cancelled === true || record.action === "cancel") return undefined; + if (typeof record.optionId === "string") return record.optionId; + if (Array.isArray(record.optionIds)) return record.optionIds[0]; + const answers = recordOf(record.answers); + if (answers) return Object.values(answers)[0]; + return response; +} + +function toolKind(name: string): "read" | "edit" | "search" | "execute" | "other" { + if (/^(read|ls)$/i.test(name)) return "read"; + if (/^(edit|write)$/i.test(name)) return "edit"; + if (/^(grep|find|search)$/i.test(name)) return "search"; + if (/^(bash|exec|shell)$/i.test(name)) return "execute"; + return "other"; +} + +function toolResultText(result: unknown): string | undefined { + const content = recordOf(result)?.content; + if (!Array.isArray(content)) return undefined; + const texts = content.flatMap((entry) => { + const record = recordOf(entry); + return record?.type === "text" && typeof record.text === "string" ? [record.text] : []; + }); + return texts.length > 0 ? texts.join("\n") : undefined; +} + +function toolResultImages(result: unknown): string[] { + const content = recordOf(result)?.content; + if (!Array.isArray(content)) return []; + return content.flatMap((entry) => { + const record = recordOf(entry); + if ( + record?.type !== "image" || + typeof record.data !== "string" || + typeof record.mimeType !== "string" + ) { + return []; + } + return [`data:${record.mimeType};base64,${record.data}`]; + }); +} + +const isSubAgentTool = (name: string): boolean => + /(?:subagent|sub-agent|delegate|spawn|task)/i.test(name); + +/** + * Structured (GUI) Pi session backed by the user's installed `pi --mode rpc` + * CLI rather than the bundled SDK. Poracode drives the installed agent over + * stdin/stdout JSONL, mirroring how the Claude/OpenCode adapters drive their + * installed CLIs — no Pi SDK is bundled into the app. + */ +export class PiRpcSession implements StructuredSessionHandle { + readonly launchOptions; + private listener: StructuredSessionListener = NOOP_LISTENER; + private readonly client: PiRpcClient; + private readonly pendingDialogs = new Map(); + private readonly openToolItems = new Map(); + private readonly unsubscribeEvents: () => void; + private readonly unsubscribeExit: () => void; + private readonly mcpExtensionPath: string | undefined; + private dialogSequence = 0; + private itemSequence = 0; + private turnSequence = 0; + private currentTurnId: string | undefined; + private turnCompletion: Promise = Promise.resolve(); + private resolveTurnCompletion: (() => void) | undefined; + private turnWatchdog: ReturnType | undefined; + private agentStarted = false; + private assistantItemId: string | undefined; + private reasoningItemId: string | undefined; + /** Canonical item mirroring the installed pi-goal extension's session state. */ + private goalItemId: string | undefined; + private goalStartedAt: number | undefined; + private lastPiGoalStatus: ProviderGoalState["status"] | undefined; + private piCodexGoal: ProviderGoalState | undefined; + /** Latest visible extension status item for each Pi plugin-defined status key. */ + private readonly extensionStatusItemIds = new Map(); + private turnErrorMessage: string | undefined; + private currentConfig: ThreadConfig; + /** Pi assigns its session id asynchronously; do not publish a placeholder. */ + private sessionRef: ReturnType | undefined; + private disposed = false; + private interruptRequested = false; + + private constructor( + private readonly input: CreateStructuredSessionInput, + client: PiRpcClient, + mcpExtensionPath?: string, + ) { + this.launchOptions = { + ...(input.agentSettings ? { agentSettings: input.agentSettings } : {}), + ...(input.mcpServers ? { mcpServers: input.mcpServers } : {}), + }; + this.client = client; + this.mcpExtensionPath = mcpExtensionPath; + this.currentConfig = input.config; + this.unsubscribeEvents = client.onEvent((event) => this.handleEvent(event)); + this.unsubscribeExit = client.onExit(() => this.handleExit()); + } + + static async create( + input: CreateStructuredSessionInput, + options?: { binary?: string }, + ): Promise { + if (input.projectLocation.kind === "wsl") { + throw new Error("Pi structured chat requires a native project; use Pi terminal mode in WSL."); + } + const cwd = input.projectLocation.path; + const binary = options?.binary ?? resolveAgentBinaryPath(input.projectLocation, "pi") ?? "pi"; + const mcpExtensionPath = + input.mcpServers && input.mcpServers.length > 0 + ? await writePiMcpExtension(input.mcpServers) + : undefined; + + const args = ["--mode", "rpc", "--approve"]; + const resumeId = input.sessionRef?.providerSessionId; + if (resumeId) args.push("--session", resumeId); + if (input.config.model) args.push("--model", input.config.model); + if ( + input.config.effort && + PI_THINKING_LEVELS.includes(input.config.effort as PiThinkingLevel) + ) { + args.push("--thinking", input.config.effort); + } + if (mcpExtensionPath) args.push("--extension", mcpExtensionPath); + + const client = PiRpcClient.spawn({ command: binary, args, cwd }); + try { + await client.spawnReady; + return new PiRpcSession(input, client, mcpExtensionPath); + } catch (error) { + await client.close(); + throw error; + } + } + + ownsProviderSession(providerSessionId: string): boolean { + return providerSessionId === this.sessionRef?.providerSessionId; + } + + setListener(listener: StructuredSessionListener): void { + this.listener = listener; + this.emit({ type: "session.started", threadId: this.input.threadId }); + this.publishUpdate("idle", "none"); + void this.publishSlashCommands(); + } + + async startTurn( + prompt: string, + config: ThreadConfig, + _segments?: PromptSegment[], + options?: StartTurnOptions, + ): Promise { + if (this.disposed) throw new Error("Pi session is closed."); + await this.applyConfig(config); + this.beginTurn(prompt, options?.userMessageItemId); + this.publishUpdate("working", "none"); + const completion = this.turnCompletion; + try { + const response = await this.client.request("prompt", { message: prompt, source: "rpc" }); + if (!response.success) { + this.failTurn(response.error ?? "Pi rejected the prompt."); + return; + } + // Extension commands and prompt handlers can complete without starting an + // agent run, so no agent_settled event follows them. If no agent run + // begins shortly after acceptance, settle the turn locally. + this.armTurnWatchdog(); + await completion; + } catch (error) { + if (this.interruptRequested) { + this.finishTurn("cancelled"); + return; + } + this.failTurn(errorMessage(error)); + throw error; + } + } + + async steerTurn( + prompt: string, + config: ThreadConfig, + _segments?: PromptSegment[], + options?: StartTurnOptions, + ): Promise { + await this.applyConfig(config); + if (!this.currentTurnId) return this.startTurn(prompt, config, undefined, options); + const response = await this.client.request("steer", { message: prompt }); + if (!response.success) { + this.emit({ + type: "warning", + threadId: this.input.threadId, + message: response.error ?? "Pi could not steer the current turn.", + }); + } + } + + async interruptTurn(): Promise { + this.interruptRequested = true; + this.cancelDialogs(); + await this.client.request("abort").catch(() => undefined); + } + + forceCompleteTurn(): void { + this.finishTurn("cancelled"); + } + + async resolveServerRequest(requestId: ThreadServerRequestId, response: unknown): Promise { + const id = String(requestId); + const dialog = this.pendingDialogs.get(id); + if (!dialog) return; + this.pendingDialogs.delete(id); + const raw = readResponseValue(response); + for (const event of buildQuestionAnswerEvents({ + threadId: this.input.threadId, + itemId: this.nextItemId("question-answer"), + questions: [ + { + keys: [id], + header: dialog.title, + question: dialog.message || dialog.title, + options: (dialog.options ?? []).map((option) => ({ optionId: option, label: option })), + }, + ], + answers: { [id]: raw }, + })) { + this.emit(event); + } + if (dialog.kind === "confirm") { + const confirmed = raw === true || raw === "yes" || raw === "accept" || raw === "allow"; + this.client.notify({ type: "extension_ui_response", id, confirmed }); + } else if (raw === undefined) { + this.client.notify({ type: "extension_ui_response", id, cancelled: true }); + } else { + this.client.notify({ type: "extension_ui_response", id, value: String(raw) }); + } + this.emit({ + type: "request.resolved", + threadId: this.input.threadId, + requestId: id, + outcome: raw === undefined ? "cancelled" : "answered", + }); + this.publishUpdate(this.currentTurnId ? "working" : "idle", "none"); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.clearTurnWatchdog(); + this.cancelDialogs(); + this.unsubscribeEvents(); + this.unsubscribeExit(); + await this.client.close(); + if (this.mcpExtensionPath) { + const { rmSync } = await import("node:fs"); + try { + rmSync(this.mcpExtensionPath, { force: true }); + } catch { + // best-effort temp cleanup + } + } + this.emit({ type: "session.exited", threadId: this.input.threadId, reason: "disposed" }); + this.listener.onClose(); + } + + private async applyConfig(config: ThreadConfig): Promise { + const selected = splitPiModelId(config.model); + if (selected) { + const current = this.currentConfig.model; + if (current !== config.model) { + const response = await this.client.request("set_model", { + provider: selected.provider, + modelId: selected.modelId, + }); + if (!response.success) + throw new Error(response.error ?? `Pi model ${config.model} is unavailable.`); + } + } + if ( + config.effort && + PI_THINKING_LEVELS.includes(config.effort as PiThinkingLevel) && + config.effort !== this.currentConfig.effort + ) { + await this.client.request("set_thinking_level", { level: config.effort }); + } + this.currentConfig = config; + } + + private beginTurn(prompt: string, userMessageItemId?: string): void { + this.currentTurnId = `pi-turn-${++this.turnSequence}`; + this.interruptRequested = false; + this.agentStarted = false; + this.turnErrorMessage = undefined; + this.assistantItemId = undefined; + this.reasoningItemId = undefined; + this.openToolItems.clear(); + this.turnCompletion = new Promise((resolve) => { + this.resolveTurnCompletion = resolve; + }); + this.emit({ + type: "turn.started", + threadId: this.input.threadId, + turnId: this.currentTurnId, + }); + // GUI launches paint the user's message optimistically before Pi accepts + // the prompt. Reuse that item id so the canonical stream confirms the + // existing row instead of creating a duplicate. + const userItemId = userMessageItemId ?? this.nextItemId("user"); + this.emit({ + type: "item.started", + threadId: this.input.threadId, + itemId: userItemId, + itemType: "user_message", + payload: { content: [{ kind: "text", text: prompt }] }, + }); + this.emit({ type: "item.completed", threadId: this.input.threadId, itemId: userItemId }); + } + + private armTurnWatchdog(): void { + this.clearTurnWatchdog(); + this.turnWatchdog = setTimeout(() => { + if (!this.agentStarted && this.currentTurnId) this.finishTurn("completed"); + }, 1_500); + } + + private clearTurnWatchdog(): void { + if (this.turnWatchdog) { + clearTimeout(this.turnWatchdog); + this.turnWatchdog = undefined; + } + } + + private handleEvent(event: PiRpcEvent): void { + if (this.disposed) return; + switch (event.type) { + case "message_update": + this.handleMessageUpdate(event); + break; + case "tool_execution_start": + this.handleToolStart(event); + break; + case "tool_execution_update": + this.handleToolUpdate(event); + break; + case "tool_execution_end": + this.handleToolEnd(event); + break; + case "agent_start": + this.agentStarted = true; + this.clearTurnWatchdog(); + break; + case "compaction_start": + this.publishUpdate("working", "none"); + break; + case "compaction_end": + if (typeof event.errorMessage === "string" && event.errorMessage) { + this.emit({ + type: "warning", + threadId: this.input.threadId, + message: event.errorMessage, + }); + } + break; + case "auto_retry_start": + if (typeof event.errorMessage === "string") { + this.emit({ + type: "warning", + threadId: this.input.threadId, + message: event.errorMessage, + }); + } + break; + case "auto_retry_end": + if (event.success === false && typeof event.finalError === "string") { + this.turnErrorMessage ??= event.finalError; + } + break; + case "agent_settled": + this.clearTurnWatchdog(); + this.finishTurn(this.interruptRequested ? "cancelled" : this.settleState()); + break; + case "extension_ui_request": + this.handleExtensionUiRequest(event); + break; + case "entry_appended": + this.handleSessionEntry(event); + break; + } + } + + private settleState(): "completed" | "failed" { + if (this.turnErrorMessage) { + this.emit({ type: "error", threadId: this.input.threadId, message: this.turnErrorMessage }); + return "failed"; + } + return "completed"; + } + + private handleMessageUpdate(event: PiRpcEvent): void { + const update = recordOf(event.assistantMessageEvent); + if (!update) return; + if (update.type === "text_delta" && typeof update.delta === "string") { + const itemId = this.ensureAssistantItem(); + this.emit({ + type: "content.delta", + threadId: this.input.threadId, + itemId, + stream: "assistant_text", + delta: update.delta, + }); + } else if (update.type === "thinking_delta" && typeof update.delta === "string") { + const itemId = this.ensureReasoningItem(); + this.emit({ + type: "content.delta", + threadId: this.input.threadId, + itemId, + stream: "reasoning_text", + delta: update.delta, + }); + } else if (update.type === "error") { + const error = recordOf(update.error); + const message = + (typeof error?.errorMessage === "string" && error.errorMessage) || + (typeof error?.message === "string" && error.message) || + "Pi model request failed."; + this.turnErrorMessage ??= message; + this.emit({ type: "error", threadId: this.input.threadId, message }); + } + } + + private handleToolStart(event: PiRpcEvent): void { + const toolCallId = String(event.toolCallId ?? ""); + const toolName = String(event.toolName ?? "tool"); + const itemId = this.nextItemId("tool"); + this.openToolItems.set(toolCallId, itemId); + const isMcp = toolName.startsWith("mcp__"); + const serverId = isMcp ? toolName.split("__")[1] : undefined; + this.emit({ + type: "item.started", + threadId: this.input.threadId, + itemId, + itemType: isMcp ? "mcp_tool_call" : "tool_call", + payload: { + name: toolName, + title: toolName, + kind: toolKind(toolName), + args: event.args, + status: "running", + ...(serverId ? { serverId } : {}), + ...(isSubAgentTool(toolName) ? { isSubAgent: true } : {}), + ...(toolName.startsWith("mcp__crossagents__") ? { isCrossagent: true } : {}), + }, + }); + } + + private handleToolUpdate(event: PiRpcEvent): void { + const itemId = this.openToolItems.get(String(event.toolCallId ?? "")); + if (!itemId) return; + const toolName = String(event.toolName ?? "tool"); + this.emit({ + type: "item.updated", + threadId: this.input.threadId, + itemId, + payload: { + name: toolName, + title: toolName, + kind: toolKind(toolName), + args: event.args, + result: event.partialResult, + status: "running", + }, + }); + } + + private handleToolEnd(event: PiRpcEvent): void { + const toolCallId = String(event.toolCallId ?? ""); + const itemId = this.openToolItems.get(toolCallId); + if (!itemId) return; + this.openToolItems.delete(toolCallId); + const toolName = String(event.toolName ?? "tool"); + const images = toolResultImages(event.result); + this.emit({ + type: "item.completed", + threadId: this.input.threadId, + itemId, + payload: { + name: toolName, + title: toolName, + kind: toolKind(toolName), + result: toolResultText(event.result) ?? event.result, + status: event.isError === true ? "error" : "success", + ...(images.length > 0 ? { images } : {}), + ...(isSubAgentTool(toolName) ? { isSubAgent: true } : {}), + ...(toolName.startsWith("mcp__crossagents__") ? { isCrossagent: true } : {}), + }, + }); + } + + private handleExtensionUiRequest(event: PiRpcEvent): void { + const id = String(event.id ?? ""); + const method = String(event.method ?? ""); + if (method === "setStatus") { + this.handleExtensionStatus(event); + return; + } + if (method === "notify") { + const message = typeof event.message === "string" ? event.message : ""; + this.emit( + event.notifyType === "error" + ? { type: "error", threadId: this.input.threadId, message } + : { type: "warning", threadId: this.input.threadId, message }, + ); + return; + } + if (method !== "select" && method !== "confirm" && method !== "input" && method !== "editor") { + return; // Unknown plugin widgets cannot be safely rendered by the host. + } + const title = typeof event.title === "string" ? event.title : method; + const message = typeof event.message === "string" ? event.message : undefined; + const options = Array.isArray(event.options) + ? event.options.filter((option): option is string => typeof option === "string") + : undefined; + const kind: PiDialogKind = method; + this.pendingDialogs.set(id, { + kind, + title, + ...(message ? { message } : {}), + ...(options?.length ? { options } : {}), + }); + const dialogOptions = (kind === "confirm" ? ["yes", "no"] : options)?.map((option) => ({ + optionId: option, + label: option, + })); + const formQuestion = { + id, + header: title, + question: message || title, + ...(dialogOptions?.length ? { options: dialogOptions } : {}), + }; + this.emit({ + type: "request.opened", + threadId: this.input.threadId, + requestId: id, + requestType: "tool_user_input", + payload: { + summary: title, + details: { userInputForm: { questions: [formQuestion] } }, + ...(dialogOptions?.length ? { options: dialogOptions } : {}), + }, + }); + this.publishUpdate("needs_reply", "needs_reply"); + } + + /** Preserve visible extension status without assigning plugin-specific semantics. */ + private handleExtensionStatus(event: PiRpcEvent): void { + const key = typeof event.statusKey === "string" ? event.statusKey.trim() : ""; + const text = typeof event.statusText === "string" ? event.statusText.trim() : ""; + // pi-goal is normalized from its durable session entry, avoiding a duplicate generic row. + if (!key || !text || key === "goal") return; + const payload = { + name: key, + title: key, + kind: "other" as const, + result: text, + status: "success" as const, + }; + const existingItemId = this.extensionStatusItemIds.get(key); + if (existingItemId) { + this.emit({ + type: "item.updated", + threadId: this.input.threadId, + itemId: existingItemId, + payload, + }); + return; + } + const itemId = this.nextItemId("plugin-status"); + this.extensionStatusItemIds.set(key, itemId); + this.emit({ + type: "item.started", + threadId: this.input.threadId, + itemId, + itemType: "dynamic_tool_call", + payload, + }); + this.emit({ type: "item.completed", threadId: this.input.threadId, itemId }); + } + + /** + * pi-goal writes durable state through `appendEntry` rather than a dedicated + * RPC method. Normalize those provider-native entries at the Pi boundary so + * the shared goal dock can remain provider-agnostic. + */ + private handleSessionEntry(event: PiRpcEvent): void { + const entry = recordOf(event.entry); + if (entry?.type !== "custom" || typeof entry.customType !== "string") return; + if (entry.customType === "goal-state") { + this.handleNarumitwGoalEntry(entry.data); + return; + } + if (entry.customType === "pi-codex-goal") { + this.handleCodexGoalEntry(entry.data); + return; + } + this.emitPluginActivity(entry.customType, entry.data); + } + + private handleNarumitwGoalEntry(rawData: unknown): void { + const data = recordOf(rawData); + if (!data || !("goal" in data)) return; + const rawGoal = data.goal; + if (rawGoal === null) { + this.clearGoalItem(); + return; + } + const goal = readNarumitwGoal(rawGoal); + if (goal) this.upsertGoalItem(goal); + } + + private handleCodexGoalEntry(rawData: unknown): void { + const entry = recordOf(rawData); + const kind = typeof entry?.kind === "string" ? entry.kind : ""; + if (kind === "set") { + const goal = readCodexGoal(entry?.goal); + if (!goal) return; + this.piCodexGoal = goal; + this.upsertGoalItem(goal); + return; + } + if (kind === "usage" && this.piCodexGoal) { + const usage = recordOf(entry?.usage); + const status = codexGoalStatus(entry?.status); + const updatedAt = toEpochSeconds(entry?.updatedAt); + this.piCodexGoal = { + ...this.piCodexGoal, + ...(status ? { status } : {}), + ...(typeof usage?.tokensUsed === "number" ? { tokensUsed: usage.tokensUsed } : {}), + ...(typeof usage?.activeSeconds === "number" + ? { timeUsedSeconds: usage.activeSeconds } + : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + }; + this.upsertGoalItem(this.piCodexGoal); + return; + } + if (kind === "clear") { + this.clearGoalItem(); + this.piCodexGoal = undefined; + } + } + + private upsertGoalItem(goal: ProviderGoalState): void { + const isNewGoal = this.goalStartedAt !== undefined && goal.createdAt !== this.goalStartedAt; + if (!this.goalItemId || isNewGoal) { + this.goalItemId = this.nextItemId("goal"); + this.goalStartedAt = goal.createdAt; + const payload = goalPayloadFromProviderState( + goal, + goal.status === "active" ? "set" : "updated", + ); + for (const runtimeEvent of startGoalItemEvents( + this.input.threadId, + this.goalItemId, + payload, + )) { + this.emit(runtimeEvent); + } + } else { + for (const runtimeEvent of updateGoalItemEvents( + this.input.threadId, + this.goalItemId, + goalPayloadFromProviderState(goal, "updated"), + )) { + this.emit(runtimeEvent); + } + } + this.lastPiGoalStatus = goal.status; + } + + private clearGoalItem(): void { + // Completion is persisted before clear; retain it, but dismiss manual clears. + if (this.goalItemId && this.lastPiGoalStatus !== "complete") { + for (const runtimeEvent of updateGoalItemEvents(this.input.threadId, this.goalItemId, { + action: "cleared", + })) { + this.emit(runtimeEvent); + } + } + this.goalItemId = undefined; + this.goalStartedAt = undefined; + this.lastPiGoalStatus = undefined; + } + + /** Render arbitrary Pi plugin session entries with their raw data intact. */ + private emitPluginActivity(customType: string, data: unknown): void { + const itemId = this.nextItemId("plugin-entry"); + this.emit({ + type: "item.started", + threadId: this.input.threadId, + itemId, + itemType: "dynamic_tool_call", + payload: { + name: customType, + title: customType, + kind: "other", + result: data, + status: "success", + }, + }); + this.emit({ type: "item.completed", threadId: this.input.threadId, itemId }); + } + + private handleExit(): void { + if (this.disposed) return; + if (this.currentTurnId) this.finishTurn("cancelled"); + const stderr = this.client.stderr; + this.emit({ + type: "session.exited", + threadId: this.input.threadId, + reason: "exited", + ...(stderr ? { errorMessage: stderr.slice(-500) } : {}), + }); + this.listener.onClose(); + } + + private ensureAssistantItem(): string { + if (this.assistantItemId) return this.assistantItemId; + const itemId = this.nextItemId("assistant"); + this.assistantItemId = itemId; + this.emit({ + type: "item.started", + threadId: this.input.threadId, + itemId, + itemType: "assistant_message", + payload: { content: [] }, + }); + return itemId; + } + + private ensureReasoningItem(): string { + if (this.reasoningItemId) return this.reasoningItemId; + const itemId = this.nextItemId("reasoning"); + this.reasoningItemId = itemId; + this.emit({ + type: "item.started", + threadId: this.input.threadId, + itemId, + itemType: "reasoning", + payload: {}, + }); + return itemId; + } + + private finishTurn(state: "completed" | "cancelled" | "failed" = "completed"): void { + if (!this.currentTurnId) return; + this.clearTurnWatchdog(); + this.completeOpenItems(); + this.emit({ + type: "turn.completed", + threadId: this.input.threadId, + turnId: this.currentTurnId, + state, + }); + this.currentTurnId = undefined; + this.interruptRequested = false; + this.publishUpdate(state === "failed" ? "error" : "idle", "none"); + this.resolveTurnCompletion?.(); + this.resolveTurnCompletion = undefined; + void this.publishContextUsage(); + void this.publishSlashCommands(); + } + + private failTurn(message: string): void { + this.emit({ type: "error", threadId: this.input.threadId, message }); + if (this.currentTurnId) { + this.clearTurnWatchdog(); + this.completeOpenItems(); + this.emit({ + type: "turn.completed", + threadId: this.input.threadId, + turnId: this.currentTurnId, + state: "failed", + }); + this.currentTurnId = undefined; + } + this.publishUpdate("error", "none", message); + this.resolveTurnCompletion?.(); + this.resolveTurnCompletion = undefined; + } + + private completeOpenItems(): void { + for (const itemId of [this.reasoningItemId, this.assistantItemId]) { + if (itemId) this.emit({ type: "item.completed", threadId: this.input.threadId, itemId }); + } + for (const itemId of this.openToolItems.values()) { + this.emit({ + type: "item.completed", + threadId: this.input.threadId, + itemId, + payload: { name: "tool", status: "error" }, + }); + } + this.reasoningItemId = undefined; + this.assistantItemId = undefined; + this.openToolItems.clear(); + } + + private async publishContextUsage(): Promise { + const response = await this.client.request("get_session_stats").catch(() => undefined); + const stats = recordOf(response?.data); + const usage = recordOf(stats?.contextUsage); + const sessionId = typeof stats?.sessionId === "string" ? stats.sessionId : ""; + if (sessionId) { + this.sessionRef = createKnownSessionRef(sessionId); + } + if (!usage) return; + const tokens = typeof usage.tokens === "number" ? usage.tokens : null; + const contextWindow = typeof usage.contextWindow === "number" ? usage.contextWindow : 0; + this.emit({ + type: "context.updated", + threadId: this.input.threadId, + usage: { + ...(tokens !== null ? { usedTokens: tokens } : {}), + ...(contextWindow > 0 ? { maxTokens: contextWindow } : {}), + }, + }); + } + + private async publishSlashCommands(): Promise { + const response = await this.client.request("get_commands").catch(() => undefined); + const list = recordOf(response?.data)?.commands; + const commands: AgentSlashCommand[] = []; + if (Array.isArray(list)) { + for (const entry of list) { + const record = recordOf(entry); + const name = typeof record?.name === "string" ? record.name : undefined; + if (!name) continue; + const description = + typeof record?.description === "string" ? record.description : undefined; + commands.push({ + id: name, + label: description ? `${name} — ${description}` : name, + ...(description ? { description } : {}), + }); + } + } + this.listener.onUpdate({ + status: this.currentTurnId ? "working" : "idle", + attention: this.pendingDialogs.size > 0 ? "needs_reply" : "none", + config: this.currentConfig, + ...(this.sessionRef ? { sessionRef: this.sessionRef } : {}), + slashCommands: commands, + }); + } + + private publishUpdate( + status: "idle" | "working" | "needs_reply" | "error", + attention: "none" | "needs_reply", + error?: string, + ): void { + this.listener.onUpdate({ + status, + attention, + config: this.currentConfig, + ...(this.sessionRef ? { sessionRef: this.sessionRef } : {}), + ...(error ? { errorMessage: error } : {}), + }); + } + + private cancelDialogs(): void { + for (const [requestId] of this.pendingDialogs) { + this.client.notify({ type: "extension_ui_response", id: requestId, cancelled: true }); + this.emit({ + type: "request.resolved", + threadId: this.input.threadId, + requestId, + outcome: "cancelled", + }); + } + this.pendingDialogs.clear(); + } + + private nextItemId(kind: string): string { + return `pi-${kind}-${++this.itemSequence}`; + } + + private emit(event: RuntimeEvent): void { + this.listener.onRuntimeEvent?.(event); + } +} + +function readNarumitwGoal(value: unknown): ProviderGoalState | undefined { + const goal = recordOf(value); + if (!goal) return undefined; + const objective = typeof goal.text === "string" ? goal.text.trim() : ""; + const status = narumitwGoalStatus(goal.status); + if (!objective || !status) return undefined; + const createdAt = toEpochSeconds(goal.startedAt); + const updatedAt = toEpochSeconds(goal.updatedAt); + return { + objective, + status, + ...(typeof goal.id === "string" ? { providerThreadId: goal.id } : {}), + ...(typeof goal.tokenBudget === "number" ? { tokenBudget: goal.tokenBudget } : {}), + ...(typeof goal.tokensUsed === "number" ? { tokensUsed: goal.tokensUsed } : {}), + ...(typeof goal.timeUsedSeconds === "number" ? { timeUsedSeconds: goal.timeUsedSeconds } : {}), + ...(typeof goal.iteration === "number" ? { iterations: goal.iteration } : {}), + ...(createdAt !== undefined ? { createdAt } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + }; +} + +function narumitwGoalStatus(value: unknown): ProviderGoalState["status"] | undefined { + switch (value) { + case "active": + case "paused": + case "complete": + return value; + case "blocked": + return "paused"; + case "usage_limited": + case "budget_limited": + return "budget_limited"; + default: + return undefined; + } +} + +function readCodexGoal(value: unknown): ProviderGoalState | undefined { + const goal = recordOf(value); + if (!goal) return undefined; + const objective = typeof goal.objective === "string" ? goal.objective.trim() : ""; + const status = codexGoalStatus(goal.status); + const usage = recordOf(goal.usage); + if (!objective || !status) return undefined; + const createdAt = toEpochSeconds(goal.createdAt); + const updatedAt = toEpochSeconds(goal.updatedAt); + return { + objective, + status, + ...(typeof goal.goalId === "string" ? { providerThreadId: goal.goalId } : {}), + ...(typeof goal.tokenBudget === "number" || goal.tokenBudget === null + ? { tokenBudget: goal.tokenBudget } + : {}), + ...(typeof usage?.tokensUsed === "number" ? { tokensUsed: usage.tokensUsed } : {}), + ...(typeof usage?.activeSeconds === "number" ? { timeUsedSeconds: usage.activeSeconds } : {}), + ...(createdAt !== undefined ? { createdAt } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + }; +} + +function codexGoalStatus(value: unknown): ProviderGoalState["status"] | undefined { + switch (value) { + case "active": + case "paused": + case "complete": + return value; + case "budgetLimited": + return "budget_limited"; + default: + return undefined; + } +} + +function toEpochSeconds(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined; + return value > 1_000_000_000_000 ? value / 1_000 : value; +} diff --git a/src/supervisor/agents/pi/sessionFiles.ts b/src/supervisor/agents/pi/sessionFiles.ts new file mode 100644 index 00000000..aade759f --- /dev/null +++ b/src/supervisor/agents/pi/sessionFiles.ts @@ -0,0 +1,159 @@ +import { existsSync } from "node:fs"; +import { open, readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import type { ProjectLocation, SessionRef } from "@/shared/contracts"; +import { + batchWslCommandsAsync, + createKnownSessionRef, + getCachedWslHomeDirectory, + readSessionFileText, + watchSessionPaths, +} from "../base"; +import { piAgentHomePath } from "./detection"; + +const preSpawnIds = new Map>(); + +function cwdOf(location: ProjectLocation): string { + return location.kind === "wsl" ? location.linuxPath : location.path; +} + +function nativeSessionsRoot(): string { + return process.env.PI_CODING_AGENT_SESSION_DIR?.trim() || join(piAgentHomePath(), "sessions"); +} + +interface NativePiSession { + id: string; + path: string; + mtimeMs: number; +} + +async function readSessionHead(path: string, bytes: number): Promise { + try { + const handle = await open(path, "r"); + try { + const buffer = Buffer.alloc(bytes); + const { bytesRead } = await handle.read(buffer, 0, bytes, 0); + return buffer.subarray(0, bytesRead).toString("utf8"); + } finally { + await handle.close(); + } + } catch { + return undefined; + } +} + +// Reads pi's on-disk session headers directly (no bundled SDK). Sessions live +// under `//.jsonl`; each file's first line is a +// `{ type: "session", id, cwd }` header we filter against the project cwd. +async function listNativePiSessions(cwd: string): Promise { + const root = nativeSessionsRoot(); + if (!existsSync(root)) return []; + let groups: string[]; + try { + groups = await readdir(root); + } catch { + return []; + } + const sessions: NativePiSession[] = []; + for (const group of groups) { + let files: string[]; + try { + files = await readdir(join(root, group)); + } catch { + continue; + } + for (const file of files) { + if (!file.endsWith(".jsonl")) continue; + const path = join(root, group, file); + const firstLine = (await readSessionHead(path, 16_384))?.split("\n", 1)[0]; + if (!firstLine) continue; + try { + const header = JSON.parse(firstLine) as { type?: unknown; id?: unknown; cwd?: unknown }; + if (header.type !== "session" || typeof header.id !== "string" || header.cwd !== cwd) { + continue; + } + const info = await stat(path).catch(() => undefined); + sessions.push({ id: header.id, path, mtimeMs: info?.mtimeMs ?? 0 }); + } catch { + // A partially written session header is skipped (retried on next poll). + } + } + } + return sessions.sort((a, b) => b.mtimeMs - a.mtimeMs); +} + +export async function snapshotPiPreSpawnSessions(location: ProjectLocation): Promise { + const cwd = cwdOf(location); + if (location.kind === "wsl") { + preSpawnIds.set(cwd, new Set()); + return; + } + const sessions = await listNativePiSessions(cwd); + preSpawnIds.set(cwd, new Set(sessions.map((session) => session.id))); +} + +export async function discoverPiSessionRef( + location: ProjectLocation, +): Promise { + if (location.kind === "wsl") { + const [result] = await batchWslCommandsAsync(location.distro, [ + 'root="${PI_CODING_AGENT_SESSION_DIR:-${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/sessions}"; test -d "$root" && find "$root" -type f -name "*.jsonl" -printf "%T@\\t%p\\n" | sort -nr | head -20', + ]); + if (!result?.ok) return undefined; + for (const line of result.stdout.split("\n")) { + const tab = line.indexOf("\t"); + const path = tab >= 0 ? line.slice(tab + 1).trim() : ""; + if (!path) continue; + const firstLine = (await readSessionFileText(location, path, 16_384))?.split("\n", 1)[0]; + if (!firstLine) continue; + try { + const header = JSON.parse(firstLine) as { type?: unknown; id?: unknown; cwd?: unknown }; + if ( + header.type === "session" && + typeof header.id === "string" && + header.cwd === location.linuxPath + ) { + return createKnownSessionRef(header.id); + } + } catch { + // A partially written session header is retried by the next discovery poll. + } + } + return undefined; + } + const cwd = cwdOf(location); + const before = preSpawnIds.get(cwd) ?? new Set(); + const sessions = await listNativePiSessions(cwd); + const latest = sessions.find((session) => !before.has(session.id)); + if (!latest) return undefined; + preSpawnIds.delete(cwd); + return createKnownSessionRef(latest.id); +} + +export function watchPiSessionRef( + location: ProjectLocation, + onChanged: () => void, +): (() => void) | undefined { + const home = + location.kind === "wsl" + ? (() => { + const wslHome = getCachedWslHomeDirectory(location.distro); + return wslHome ? `${wslHome}/.pi/agent` : undefined; + })() + : piAgentHomePath(); + if (!home) return undefined; + const sessions = location.kind === "wsl" ? `${home}/sessions` : join(home, "sessions"); + const paths = + location.kind === "wsl" + ? [sessions, home] + : [sessions, home].filter((path) => existsSync(path)); + return watchSessionPaths(location, paths, onChanged, `pi:${location.kind}`); +} + +export async function resolveNativePiSessionPath( + cwd: string, + sessionId: string, +): Promise { + const sessions = await listNativePiSessions(cwd); + return sessions.find((session) => session.id === sessionId)?.path; +} diff --git a/src/supervisor/agents/pi/terminal.ts b/src/supervisor/agents/pi/terminal.ts new file mode 100644 index 00000000..3660f02d --- /dev/null +++ b/src/supervisor/agents/pi/terminal.ts @@ -0,0 +1,18 @@ +import type { TerminalStatusHint } from "../base"; + +export function detectPiTerminalStatus(text: string): TerminalStatusHint | null { + const tail = text.slice(-8_000); + if (/\b(aborted|cancelled)\b/i.test(tail)) { + return { status: "idle", attention: "none", corroborated: true }; + } + if (/\b(error|failed)\b[^\n]*$/i.test(tail)) { + return { status: "idle", attention: "none" }; + } + if (/esc\s+to\s+(?:abort|cancel)|ctrl-c\s+to\s+(?:abort|cancel)/i.test(tail)) { + return { status: "working", attention: "none" }; + } + if (/\b(?:tokens?|cost)\b[^\n]*(?:\/|\||\$)[^\n]*$/i.test(tail)) { + return { status: "idle", attention: "none" }; + } + return null; +} diff --git a/src/supervisor/agents/registry.test.ts b/src/supervisor/agents/registry.test.ts index b477c185..d19469f5 100644 --- a/src/supervisor/agents/registry.test.ts +++ b/src/supervisor/agents/registry.test.ts @@ -16,6 +16,7 @@ const EXPECTED_BUILT_IN_ORDER = [ "commandcode", "cursor", "opencode", + "pi", "factory", ] as const; @@ -32,6 +33,7 @@ const EXPECTED_SUBAGENT_APPROVAL_POLICY: Record<(typeof EXPECTED_BUILT_IN_ORDER) commandcode: "yolo", cursor: "never", opencode: "yolo", + pi: "never", factory: "auto-high", }; diff --git a/src/supervisor/agents/registry.ts b/src/supervisor/agents/registry.ts index 4cd97a4c..a26b8b39 100644 --- a/src/supervisor/agents/registry.ts +++ b/src/supervisor/agents/registry.ts @@ -25,6 +25,7 @@ import { createGeminiAdapter } from "./gemini"; import { createGrokAdapter } from "./grok"; import { createKimiAdapter } from "./kimi"; import { createOpenCodeAdapter } from "./opencode"; +import { createPiAdapter } from "./pi"; import { createQwenAdapter } from "./qwen"; export function createAgentRegistry(): AgentAdapter[] { @@ -49,6 +50,7 @@ export function buildAgentRegistry(userInstances: AgentInstanceConfig[]): AgentA createCommandCodeAdapter(), createCursorAdapter(), createOpenCodeAdapter(), + createPiAdapter(), createFactoryAdapter(), ]; const userAdapters = userInstances diff --git a/src/supervisor/skills/SkillsService.ts b/src/supervisor/skills/SkillsService.ts index 95bee85d..209f197d 100644 --- a/src/supervisor/skills/SkillsService.ts +++ b/src/supervisor/skills/SkillsService.ts @@ -1294,6 +1294,10 @@ export class SkillsService { enabled: boolean, ): Promise { const moves: LinkedImportMove[] = []; + // macOS commonly exposes the temporary directory through `/var` while + // `realpath()` canonicalizes it to `/private/var`. Compare canonical paths + // on both sides so linked imports still follow their provider source. + const resolvedSourcePath = await realpath(sourcePath).catch(() => sourcePath); for (const availability of ["shared", "poracode"] as const) { const managedRoot = this.managedRoot(environment, "global", availability); const sourceRoot = enabled ? disabledRoot(managedRoot.fsPath) : managedRoot.fsPath; @@ -1314,7 +1318,7 @@ export class SkillsService { } catch { continue; } - if (normalizePath(targetPath) !== normalizePath(sourcePath)) continue; + if (normalizePath(targetPath) !== normalizePath(resolvedSourcePath)) continue; const destinationPath = join(destinationRoot, linkName); if (await pathExists(destinationPath)) { throw new Error(`A linked skill named ${linkName} already exists in the destination.`); diff --git a/tests/integration/providers-lifecycle.integration.test.ts b/tests/integration/providers-lifecycle.integration.test.ts index 68938e56..5fbe2800 100644 --- a/tests/integration/providers-lifecycle.integration.test.ts +++ b/tests/integration/providers-lifecycle.integration.test.ts @@ -30,7 +30,7 @@ const TURN_COMPLETE_TIMEOUT_MS = 180_000; const SCROLLBACK_WAIT_TIMEOUT_MS = 120_000; // Hand-picked cheapest model per provider. For dynamic-model providers -// (Codex / Copilot / Qwen / Grok / OpenCode), we fall back to scanning the detected +// (Codex / Copilot / Qwen / Grok / OpenCode / Pi), we fall back to scanning the detected // capabilities for a "mini/flash/lite/haiku/small/fast" name, then the first // model. None of these defaults are guaranteed to exist on every host — the // test will surface a clear error if the chosen model is rejected by the CLI.