From 5f1a56ea3102016a0d77718b35bb8dd103506cb8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 21 Oct 2026 15:22:37 -0500 Subject: [PATCH] Default new Pi agents to Paperclip Runner Co-Authored-By: Paperclip --- doc/DEVELOPING.md | 25 ++++--- maps/agent-access-map/runs-adapters.md | 8 ++- maps/feature-map/runs-adapters.md | 9 ++- .../src/paperclip-runner-permissions.test.ts | 6 ++ .../src/paperclip-runner-permissions.ts | 8 ++- .../adapter-utils/src/server-utils.test.ts | 7 ++ packages/adapter-utils/src/server-utils.ts | 2 +- .../adapters/pi-local/src/ui/build-config.ts | 10 ++- packages/shared/src/agent-runner.ts | 5 +- .../agent-adapter-validation-routes.test.ts | 35 +++++++++- .../agent-permissions-routes.test.ts | 4 +- server/src/__tests__/dot-runner.test.ts | 2 +- .../native-status-arbiter-corpus.test.ts | 2 +- server/src/routes/agents.ts | 11 +-- .../services/agent-runner-selection.test.ts | 47 ++++++++++++- server/src/services/agent-runner-selection.ts | 54 +++++++++++++-- server/src/services/built-in-agents.ts | 2 +- server/src/services/company-portability.ts | 4 +- .../native-runtime/runtime-mode.test.ts | 16 +++-- .../services/native-runtime/runtime-mode.ts | 2 +- .../adapters/adapter-display-registry.test.ts | 1 + ui/src/adapters/adapter-display-registry.ts | 2 +- ui/src/components/AgentActionButtons.tsx | 2 +- .../AgentConfigForm.render.test.tsx | 10 ++- ui/src/components/AgentConfigForm.tsx | 5 +- .../components/ConfigureBuiltInAgentModal.tsx | 6 +- ui/src/components/OnboardingWizard.tsx | 15 +++-- ui/src/components/new-agent/NewAgentSetup.tsx | 67 +++++++++++-------- ui/src/pages/CompanyImport.test.tsx | 20 ++++++ ui/src/pages/CompanyImport.tsx | 9 +-- 30 files changed, 303 insertions(+), 93 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index bec91b9fe0c..1c7085ffe52 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1933,22 +1933,22 @@ agent conversion are outside this playbook. ## Paperclip Runner Adapter Conversion -Paperclip Runner offers native Codex, OpenCode, and **ACPX Claude**. New +Paperclip Runner offers native Codex, OpenCode, **ACPX Claude**, and **ACPX Pi**. New Claude Code agents default to ACPX Claude on Apple Silicon, Intel Mac, Linux x64 -and Linux ARM64 with supported glibc. New OpenCode agents default to native -OpenCode on Apple Silicon, Intel Mac, and Linux x64 with supported glibc; -Linux ARM64 remains Legacy until the pinned OpenCode runtime supports it. +and Linux ARM64 with supported glibc. New OpenCode and Pi agents default to their native +runtimes on Apple Silicon, Intel Mac, and Linux x64 with supported glibc; +Linux ARM64 remains Legacy until the pinned runtimes support it. Unsupported or unqualified targets retain Legacy. Claude over Darwin SSH keeps an automatic Legacy default; explicit native execution requires matching remote assets. The runner override is in Advanced. Ordinary Claude models default to `claude-opus-5-5`; explicit models -and provider routing remain authoritative. Existing agents keep their saved runner. Converting an existing Claude, Codex, or OpenCode agent selects its +and provider routing remain authoritative. Existing agents keep their saved runner. Converting an existing Claude, Codex, OpenCode, or Pi agent selects its corresponding provider, preserves compatible models, credentials, workspace, and instructions, and resets execution sessions while retaining run history. Other adapters require an explicit provider choice. Legacy ACPX Codex agent settings normalize to native Codex on configuration updates and before fresh runs; immutable run descriptors remain readable. The **Paperclip Runner** experimental setting applies to other native providers. Native Codex, OpenCode, -and ACPX Claude do not require it. Company access checks still apply. +ACPX Claude, and ACPX Pi do not require it. Company access checks still apply. OpenCode requires an explicit `provider/model` before testing or finishing setup. An inactive setup draft can be saved before choosing a model. Automatic native @@ -1956,8 +1956,17 @@ selection uses `allow` permissions and a `per_turn` lifecycle. Native `ask` and `deny` policies remain available in agent configuration. Legacy custom commands, extra arguments, model variants, and inherited permission rules require the Legacy runner; incompatible settings return remediation instead of being ignored. -Saved OpenCode agents keep their runner and runtime policies. Pi defaults are -unchanged. +Saved OpenCode agents keep their runner and runtime policies. + +Pi also requires an explicit `provider/model` before testing or hiring; inactive +setup drafts may precede model selection. Install its pinned runtime on the +execution host with `paperclipai runtime setup pi` before testing the connection. +The native default uses `approve-all` permissions, `per_turn` lifecycle, and +`low` thinking. Exact native thinking levels are `off`, `low`, `high`, and `max`. +Legacy `minimal`, `medium`, and `xhigh` thinking, custom commands/arguments, and +raw stdout mode require Legacy. Switching back to Legacy rejects native `max` +thinking, nondefault permission policy, or warm lifecycle instead of discarding +them. Existing Pi agents retain their saved runner and native policies. Agent configuration uses the same section layout across adapters: model and provider belong to **Adapter**, environment variables have their own section, diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 7bfef0f5e13..84fab6cb950 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -70,7 +70,13 @@ flag on macOS and GNU/Linux x64; Linux ARM64 retains Legacy. An explicit inactive setup drafts may omit it. Saved runners and permission/lifecycle policies remain authoritative. Actor/company checks and board-only discovery remain unchanged. This is scoped source/control evidence for OpenCode selection, -not live provider execution or agent authorization proof; Pi is unchanged. +not live provider execution or agent authorization proof. +Pi follows the same supported platform/default and model-free inactive-draft +boundary, with explicit pinned-runtime setup required on the execution host. +Native ACPX Pi is admitted with the generic flag off. Existing direct Pi agents +remain Legacy; saved native policies remain authoritative. Incompatible thinking, +launch, permission, or lifecycle settings return remediation on conversion. +This adds no agent permissions, cross-company access, or runtime installation API. An absent forced-Kubernetes environment is an unqualified sandbox target, never a local target. Discovery and automatic creation keep the Legacy path without provisioning it; explicit Paperclip selection returns qualification guidance. diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 7b40cf08ee3..1767da493b0 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -73,7 +73,14 @@ unrelated edit. Check that saved native permission/lifecycle choices survive resuming setup. Custom commands, arguments, variants, and inherited permissions must give Legacy remediation when native is selected. A live task with the selected account is separate proof from these setup and persistence controls. -Pi retains its existing default and is outside this rollout. +Repeat with Pi on macOS or GNU/Linux x64 after explicit operator setup of the +pinned runtime (`paperclipai runtime setup pi`). Linux ARM64 retains Legacy. +Resume an inactive model-free draft, connect an account, choose a `provider/model`, +and finish setup with the generic native flag off. Verify Pi branding and native +ACPX Pi execution by completing a simple assigned task. Preserve saved native +thinking/permission/lifecycle settings on resume and unrelated edits. Native +thinking supports `off`, `low`, `high`, `max`; legacy aliases must give remediation, +and returning to Legacy must reject settings it cannot honor. On a fresh forced-Kubernetes instance without an environment row, inspect discovery and create with automatic selection. Both must retain Legacy without provisioning; an explicit Paperclip request must give target-qualification guidance. Begin the diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts index cd61414cc4c..fcaeea10fca 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts @@ -43,10 +43,16 @@ describe("Paperclip Runner permission defaults", () => { ["claude_local", "acpxPermissionMode", "approve-all"], ["codex_local", "codexPermissionMode", "never"], ["opencode_local", "opencodePermissionMode", "allow"], + ["pi_local", "acpxPermissionMode", "approve-all"], ])("uses full auto when converting %s to the new runner", (adapter, key, value) => { expect(paperclipRunnerTransitionConfig(adapter, undefined)).toMatchObject({ [key]: value }); }); + it("keeps Pi models explicit during conversion", () => { + expect(paperclipRunnerTransitionConfig("pi_local", undefined)).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: "" }); + expect(paperclipRunnerTransitionConfig("pi_local", "openrouter/provider/model")).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: "openrouter/provider/model" }); + }); + it("recognizes only exact provider identifiers", () => { expect(isPaperclipRunnerProvider("codex")).toBe(true); expect(isPaperclipRunnerProvider("opencode")).toBe(true); diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index bb162657f73..3cb2e323e96 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -205,7 +205,7 @@ export function paperclipRunnerTransitionConfig( providerOverride?: unknown, ): Record { const previousProvider = - previousAdapterType === "claude_local" + (previousAdapterType === "claude_local" || previousAdapterType === "pi_local") ? "acpx" : previousAdapterType === "opencode_local" ? "opencode" @@ -218,11 +218,13 @@ export function paperclipRunnerTransitionConfig( : previousProvider; return { provider, - model: resolvePaperclipRunnerModel( + model: provider === "acpx" && previousAdapterType === "pi_local" + ? typeof previousModel === "string" ? previousModel.trim() : "" + : resolvePaperclipRunnerModel( provider, provider === previousProvider ? previousModel : undefined, ), - ...(provider === "acpx" ? { acpxAgent: "claude" } : {}), + ...(provider === "acpx" ? { acpxAgent: previousAdapterType === "pi_local" ? "pi" : "claude" } : {}), [PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[provider].configKey]: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[provider].defaultMode, lifecycleMode: "per_turn", diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 0523c4d09eb..083196829bc 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -20,6 +20,7 @@ import { isPaperclipExternalChatQuestionResponseTurn, isPaperclipExternalChatTurn, materializePaperclipSkillCopy, + normalizePaperclipRunnerAdapterConfig, PAPERCLIP_OPERATIONAL_SKILL_KEY, PAPERCLIP_FEEDBACK_SKILL_KEYS, refreshPaperclipWorkspaceEnvForExecution, @@ -43,6 +44,12 @@ import { WATCHDOG_DEFAULT_MANDATE, } from "./server-utils.js"; +it("keeps Pi draft models empty and preserves saved native Pi settings", () => { + expect(normalizePaperclipRunnerAdapterConfig("paperclip_runner", { provider: "acpx", acpxAgent: "pi", model: "" })).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: "" }); + const saved = { provider: "acpx", acpxAgent: "pi", model: "openrouter/provider/model", piThinkingLevel: "max", acpxPermissionMode: "approve-reads", lifecycleMode: "warm" }; + expect(normalizePaperclipRunnerAdapterConfig("paperclip_runner", saved)).toMatchObject(saved); +}); + it("reserves identity credentials even for mixed-case inherited or configured keys", () => { const keys = ["PAPERCLIP_AGENT_KEY_ID", "Paperclip_Agent_Public_Key", "paperclip_agent_private_key"]; const inherited = Object.fromEntries(keys.map(key => [key, "host-override"])); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 26dac29cd9e..a20f02e5a16 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -4299,7 +4299,7 @@ export function normalizePaperclipRunnerAdapterConfig( } if (next.provider === "acpx") { next.acpxAgent ??= "claude"; - next.model = resolvePaperclipRunnerModel("acpx", config.model); + if (next.acpxAgent !== "pi") next.model = resolvePaperclipRunnerModel("acpx", config.model); } return normalizePaperclipOperationalSkillPreference(adapterType, next); } diff --git a/packages/adapters/pi-local/src/ui/build-config.ts b/packages/adapters/pi-local/src/ui/build-config.ts index 06927f6f8d8..cfe790bba79 100644 --- a/packages/adapters/pi-local/src/ui/build-config.ts +++ b/packages/adapters/pi-local/src/ui/build-config.ts @@ -5,7 +5,15 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record { const app = await createApp(); const res = await requestApp(app, baseUrl => request(baseUrl) .post("/api/companies/company-1/adapters/paperclip_runner/test-environment") - .send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "pi" } })); + .send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "grok" } })); expect(res.status, JSON.stringify(res.body)).toBe(422); expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); expect(probe).not.toHaveBeenCalled(); @@ -793,9 +793,40 @@ describe("agent routes adapter validation", () => { expect(mockAgentService.create).not.toHaveBeenCalled(); }); + it.each(["auto", "legacy"] as const)("creates Pi through the public route with %s selection and the experimental flag off", async runner => { + const selection = await import("../services/agent-runner-selection.js"); + vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } })); + const app = await createApp(); + const model = "openrouter/anthropic/claude-sonnet-4.6"; + const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Pi", adapterType: "pi_local", runner, adapterConfig: { model } })); + expect(response.status, JSON.stringify(response.body)).toBe(201); + expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: runner === "legacy" ? "pi_local" : "paperclip_runner", adapterConfig: runner === "legacy" ? { model } : { provider: "acpx", acpxAgent: "pi", model, piThinkingLevel: "low", acpxPermissionMode: "approve-all" } }); + }); + + it("allows model-free Pi only as an inactive setup draft", async () => { + const selection = await import("../services/agent-runner-selection.js"); + vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } })); + const originalCreate = mockAgentService.create.getMockImplementation()!; + mockAgentService.create.mockImplementation(async (...args) => ({ ...await originalCreate(...args), lifecycleHolds: ["setup"] })); + const app = await createApp(); + const input = { name: "Pi draft", adapterType: "pi_local", adapterConfig: {} }; + const draft = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents") + .send({ ...input, setupId: "00000000-0000-4000-8000-000000000092" })); + expect(draft.status, JSON.stringify(draft.body)).toBe(201); + expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "" } }); + expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ setupOnly: true, runnerResolved: true }); + mockAgentService.create.mockClear(); + const active = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents") + .send({ ...input, setupOnly: true })); + expect(active.status).toBe(422); + expect(mockAgentService.create).not.toHaveBeenCalled(); + }); + it("keeps other native provider selection behind its existing flag", async () => { const app = await createApp(); - const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Pi", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" } })); + const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Grok", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "openrouter/deepseek/deepseek-v4-flash-0731" } })); expect(response.status).toBe(422); expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 95ee50519cc..1d6e20cdb22 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1837,7 +1837,7 @@ describe("agent permission routes", () => { for (const adapterCase of sshCapableAdapterCases) { it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => { - const expectedAdapterType = ["codex_local", "claude_local", "opencode_local"].includes(adapterCase.adapterType) ? "paperclip_runner" : adapterCase.adapterType; + const expectedAdapterType = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterCase.adapterType) ? "paperclip_runner" : adapterCase.adapterType; const environmentId = "33333333-3333-4333-8333-333333333333"; mockEnvironmentService.getById.mockResolvedValue({ id: environmentId, @@ -1879,7 +1879,7 @@ describe("agent permission routes", () => { }), { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); - if (["codex_local", "claude_local", "opencode_local"].includes(adapterCase.adapterType)) { + if (["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterCase.adapterType)) { if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" }); else if (adapterCase.adapterType === "opencode_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "opencode", model: adapterCase.adapterConfig.model }); else expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" }); diff --git a/server/src/__tests__/dot-runner.test.ts b/server/src/__tests__/dot-runner.test.ts index c1e34beb646..d1448197f34 100644 --- a/server/src/__tests__/dot-runner.test.ts +++ b/server/src/__tests__/dot-runner.test.ts @@ -109,7 +109,7 @@ describe("durable Dot Runner integration", () => { expect(() => resolveNativeRuntimeMode(input)).toThrow("experimental settings"); expect(resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true })).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } }); expect(() => resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true, - adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "anthropic/claude-sonnet-4-6" }, + adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "anthropic/claude-sonnet-4-6" }, })).toThrow("Paperclip Runner is experimental and disabled"); expect(resolveHeartbeatNativeRuntimeMode({ ...input, enabled: false, dotEnabled: false, persisted: { runtimeMode: "native", runtimeModeReason: null, runtimeModeResolvedAt: new Date(), driverKind: "openai_dot_mcp" }, diff --git a/server/src/__tests__/native-status-arbiter-corpus.test.ts b/server/src/__tests__/native-status-arbiter-corpus.test.ts index 432f04603f0..6f58845dfd1 100644 --- a/server/src/__tests__/native-status-arbiter-corpus.test.ts +++ b/server/src/__tests__/native-status-arbiter-corpus.test.ts @@ -850,7 +850,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { ) { const experimentalAdapterConfig = { provider: "acpx", - acpxAgent: "pi", + acpxAgent: "grok", model: "anthropic/claude-sonnet-4-6", }; const runtimeConfig = { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f519ed3991a..9cde76476bc 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1865,7 +1865,7 @@ export function agentRoutes( throw unprocessable("Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner before connecting this agent.", { code: "paperclip_runner_grok_disabled" }); } else if (!([undefined, "codex", "opencode"].includes(asRecord(config)?.provider as string | undefined)) && !(asRecord(config)?.provider === "acpx" && asRecord(config)?.acpxAgent === "copilot") && - agentHarnessType("paperclip_runner", asRecord(config) ?? {}) !== "claude_local" && + !["claude_local", "pi_local"].includes(agentHarnessType("paperclip_runner", asRecord(config) ?? {})) && experimental.enableNativeRunner !== true) { throw unprocessable( "Paperclip Runner is experimental and disabled on this instance.", @@ -1936,7 +1936,7 @@ export function agentRoutes( } if (["openai_dot", "muse"].includes(String(input.nextAdapterConfig.provider))) return input.nextAdapterConfig; const defaults = paperclipRunnerTransitionConfig(input.previousAdapterType, input.previousAdapterConfig.model, input.nextAdapterConfig.provider); - if (!["claude_local", "codex_local", "opencode_local"].includes(input.previousAdapterType) + if (!["claude_local", "codex_local", "opencode_local", "pi_local"].includes(input.previousAdapterType) && !isPaperclipRunnerProvider(input.nextAdapterConfig.provider)) { throw unprocessable("Select a Paperclip Runner provider before converting this agent."); } @@ -2175,11 +2175,12 @@ export function agentRoutes( adapterType: input.adapterType ?? null, }, ); - const incompleteOpenCodeSetup = input.setupOnly && input.adapterType === "paperclip_runner" - && normalizedAdapterConfig.provider === "opencode" + const incompleteModelSetup = input.setupOnly && input.adapterType === "paperclip_runner" + && (normalizedAdapterConfig.provider === "opencode" + || (normalizedAdapterConfig.provider === "acpx" && normalizedAdapterConfig.acpxAgent === "pi")) && (normalizedAdapterConfig.model === undefined || normalizedAdapterConfig.model === ""); // Inactive drafts can precede model selection. Test/hire always validate the complete profile. - if (!incompleteOpenCodeSetup) await assertAdapterConfigConstraints( + if (!incompleteModelSetup) await assertAdapterConfigConstraints( input.companyId, input.adapterType, input.constraintAdapterConfig diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index d598e0baf28..3ab8cef0536 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -74,9 +74,49 @@ describe("server-owned Codex runner selection", () => { expect(resolveNewAgentRunner(resolved)).toEqual(resolved); expect(input.adapterConfig).toHaveProperty("search", false); }); - it.each(["grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => { + it.each(["grok_local", "cursor", "process", "external"])("keeps %s default unchanged", adapterType => { expect(resolveNewAgentRunner({ adapterType, target, adapterConfig: { custom: "preserved" } })).toEqual({ adapterType, adapterConfig: { custom: "preserved" } }); }); + it.each(qualifiedTargets.filter(target => !(target.platform === "linux" && target.architecture === "arm64")))("defaults Pi to native on $platform/$architecture and round trips exact thinking", target => { + const adapterConfig = { model: "openrouter/anthropic/claude-sonnet-4.6", thinking: "high", command: "pi", env: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: "connection" } }, cwd: "/workspace" }; + const selected = resolveNewAgentRunner({ adapterType: "pi_local", adapterConfig, target }); + expect(agentRunnerAvailability("pi_local", target).defaultRunner).toBe("paperclip"); + expect(selected).toEqual({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: adapterConfig.model, piThinkingLevel: "high", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn", cwd: adapterConfig.cwd, env: adapterConfig.env } }); + expect(resolveNewAgentRunner(selected)).toEqual(selected); + expect(resolveNewAgentRunner({ ...selected, runner: "legacy" })).toEqual({ adapterType: "pi_local", adapterConfig: { model: adapterConfig.model, thinking: "high", cwd: adapterConfig.cwd, env: adapterConfig.env } }); + }); + it.each(["minimal", "medium", "xhigh"])("keeps unsupported Pi thinking %s explicit", thinking => { + const input = { adapterType: "pi_local", target, adapterConfig: { model: "custom/model", thinking } }; + expect(() => resolveNewAgentRunner(input)).toThrow("Legacy runner"); + expect(resolveNewAgentRunner({ ...input, runner: "legacy" })).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig }); + }); + it.each([{ command: "custom-pi" }, { args: ["--extension", "custom.ts"] }, { extraArgs: "--no-tools" }, { stdoutLogMode: "raw" }, { thinking: "low", piThinkingLevel: "high" }])("rejects unsupported Pi configuration %j", extra => { + expect(() => resolveNewAgentRunner({ adapterType: "pi_local", target, adapterConfig: { model: "custom/model", ...extra } })).toThrow("Legacy runner"); + }); + it.each([undefined, "", "model", "provider/"])("requires a Pi provider/model outside inactive setup: %s", model => { + expect(() => resolveNewAgentRunner({ adapterType: "pi_local", target, adapterConfig: { model } })).toThrow("provider/model"); + }); + it("allows an inactive Pi draft before model selection and preserves saved native policy", () => { + expect(resolveNewAgentRunner({ adapterType: "pi_local", target, setupOnly: true })).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "", piThinkingLevel: "low" } }); + const saved = { adapterType: "paperclip_runner", target, adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "custom/model", piThinkingLevel: "max", acpxPermissionMode: "approve-reads", lifecycleMode: "warm", idleTimeoutMs: 1234 } }; + expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + expect(() => resolveNewAgentRunner({ ...saved, runner: "legacy" })).toThrow("Legacy Pi cannot honor"); + }); + it.each([{ driver: "local", platform: "win32", architecture: "x64" }, { driver: "local", platform: "linux", architecture: "arm64", libc: "glibc" as const, glibcVersion: "2.39" }, { driver: "ssh", platform: "linux", architecture: "arm64", libc: "glibc" as const, glibcVersion: "2.39" }, { driver: "sandbox" }, { driver: "local", platform: "linux", architecture: "x64", libc: "musl" as const }])("keeps Pi legacy on unqualified $driver/$platform targets", target => { + const input = { adapterType: "pi_local", adapterConfig: { model: "custom/model", thinking: "medium" }, target }; + expect(agentRunnerAvailability("pi_local", target).defaultRunner).toBe("legacy"); + expect(resolveNewAgentRunner(input)).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig }); + expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable"); + }); + it("honors disabled and overridden Pi adapters", () => { + const input = { adapterType: "pi_local", target, adapterConfig: { model: "custom/model" } }; + state.overridden.add("pi_local"); + expect(resolveNewAgentRunner(input).adapterType).toBe("pi_local"); + state.overridden.clear(); state.disabled = ["paperclip_runner"]; + expect(resolveNewAgentRunner(input).adapterType).toBe("pi_local"); + state.disabled = ["pi_local"]; + expect(() => resolveNewAgentRunner(input)).toThrow("Pi is disabled"); + }); it.each(qualifiedTargets.filter(target => !(target.platform === "linux" && target.architecture === "arm64")))("defaults OpenCode to its native provider on $platform/$architecture", target => { const adapterConfig = { model: "openrouter/anthropic/claude-sonnet-4.6", dangerouslySkipPermissions: true, command: "opencode", env: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: "connection" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md" }; const selected = resolveNewAgentRunner({ adapterType: "opencode_local", adapterConfig, target }); @@ -422,11 +462,12 @@ describe("server-owned Codex runner selection", () => { }); describe("Codex creation runner contract", () => { - it("graduates only Codex and retains saved provider identity", () => { + it("graduates qualified harnesses and retains saved provider identity", () => { expect(paperclipRunnerProfileForHarness("codex_local")).toEqual({ provider: "codex" }); expect(paperclipRunnerProfileForHarness("claude_local")).toEqual({ provider: "acpx", acpxAgent: "claude" }); expect(paperclipRunnerProfileForHarness("opencode_local")).toEqual({ provider: "opencode" }); - for (const harness of ["grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined(); + expect(paperclipRunnerProfileForHarness("pi_local")).toEqual({ provider: "acpx", acpxAgent: "pi" }); + for (const harness of ["grok_local", "cursor", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined(); expect(agentHarnessType("paperclip_runner", { provider: "codex" })).toBe("codex_local"); expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "claude" })).toBe("claude_local"); expect(agentHarnessType("paperclip_runner", { provider: "unknown" })).toBe("unknown"); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index 27c3fb80e2f..ca4dd1db259 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -23,7 +23,7 @@ export interface AgentRunnerSelection { function runnerAdaptersAvailable(harness: string, driver: string): boolean { const disabled = new Set(getDisabledAdapterTypes()); - return ["codex_local", "claude_local", "opencode_local"].includes(harness) + return ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(harness) && !disabled.has(harness) && !disabled.has("paperclip_runner") && !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner") && !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner") @@ -45,6 +45,28 @@ function incompatible(fields: string[]): never { throw unprocessable(`Paperclip Runner cannot honor these settings: ${fields.join(", ")}. Remove them or select Legacy runner in Advanced.`, { code: "agent_runner_config_incompatible", fields }); } +function resolvePiRunnerConfig(config: Record): Record { + const unsupported = ["extraArgs", "args", "modelReasoningEffort", "reasoningEffort", "effort"] + .filter(key => config[key] !== undefined && config[key] !== "" && !(Array.isArray(config[key]) && config[key].length === 0)); + if (config.command && config.command !== "pi") unsupported.push("command"); + if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model"); + if (config.stdoutLogMode !== undefined && config.stdoutLogMode !== "compact") unsupported.push("stdoutLogMode"); + const thinking = config.piThinkingLevel ?? config.thinking; + if (thinking !== undefined && !["off", "low", "high", "max"].includes(String(thinking))) unsupported.push("thinking"); + if (config.piThinkingLevel !== undefined && config.thinking !== undefined && config.piThinkingLevel !== config.thinking) unsupported.push("thinking", "piThinkingLevel"); + if (unsupported.length) incompatible(unsupported); + const translated = { ...config }; + for (const key of ["command", "extraArgs", "args", "thinking", "stdoutLogMode"]) delete translated[key]; + return { + ...translated, + provider: "acpx", acpxAgent: "pi", + model: typeof config.model === "string" ? config.model.trim() : "", + piThinkingLevel: thinking ?? "low", + acpxPermissionMode: config.acpxPermissionMode ?? "approve-all", + lifecycleMode: config.lifecycleMode ?? "per_turn", + }; +} + function resolveOpenCodeRunnerConfig(config: Record): Record { const unsupported = ["extraArgs", "args", "variant", "modelReasoningEffort", "reasoningEffort", "effort"] .filter(key => config[key] !== undefined && config[key] !== "" && !(Array.isArray(config[key]) && config[key].length === 0)); @@ -103,12 +125,25 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp let config = { ...input.adapterConfig }; const harness = agentHarnessType(adapterType, config); const native = adapterType === "paperclip_runner"; - if (["codex_local", "claude_local", "opencode_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "opencode_local" ? "OpenCode" : adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" }); + if (["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "pi_local" ? "Pi" : adapterType === "opencode_local" ? "OpenCode" : adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" }); if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; const availability = agentRunnerAvailability(harness, input.target); const choice = input.runner ?? "auto"; const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip"); if (!useNative) { + if (harness === "pi_local") { + if (!native) return { adapterType, adapterConfig: config }; + const unsupported = [ + ...(config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all" ? ["acpxPermissionMode"] : []), + ...(config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn" ? ["lifecycleMode"] : []), + ...(config.acpxSessionMode !== undefined ? ["acpxSessionMode"] : []), + ...(config.piThinkingLevel !== undefined && !["off", "low", "high"].includes(String(config.piThinkingLevel)) ? ["piThinkingLevel"] : []), + ]; + if (unsupported.length) throw unprocessable(`Legacy Pi cannot honor these settings: ${unsupported.join(", ")}. Keep Paperclip Runner or explicitly choose compatible settings.`, { code: "agent_runner_config_incompatible", fields: unsupported }); + const thinking = config.piThinkingLevel ?? "low"; + for (const key of ["provider", "acpxAgent", "acpxPermissionMode", "acpxSessionMode", "piThinkingLevel", "lifecycleMode", "idleTimeoutMs"]) delete config[key]; + return { adapterType: "pi_local", adapterConfig: { ...config, thinking } }; + } if (harness === "opencode_local") { if (!native) return { adapterType, adapterConfig: config }; if (config.opencodePermissionMode !== undefined && config.opencodePermissionMode !== "allow") incompatible(["opencodePermissionMode"]); @@ -138,6 +173,17 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp ? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced." : "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" }); if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; + if (harness === "pi_local") { + config = resolvePiRunnerConfig(config); + if (input.setupOnly && !config.model) return { adapterType: "paperclip_runner", adapterConfig: config }; + if (typeof config.model !== "string" || !/^[^/\s]+\/\S+$/.test(config.model)) throw unprocessable("Pi requires an explicit provider/model. Select a model before testing or hiring.", { code: "agent_runner_config_incompatible", fields: ["model"] }); + try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); } + catch (error) { + if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code }); + throw error; + } + return { adapterType: "paperclip_runner", adapterConfig: config }; + } if (harness === "opencode_local") { config = resolveOpenCodeRunnerConfig(config); if (input.setupOnly && !config.model) return { adapterType: "paperclip_runner", adapterConfig: config }; @@ -212,7 +258,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp } export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) { - if (!["codex_local", "claude_local", "opencode_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig)) + if (!["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig)) || input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input); const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId); return resolveNewAgentRunner({ ...input, target }); @@ -249,7 +295,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri const qualified = await readSandboxPlatformQualification(db, environment, companyId); if (qualified) target = { driver, ...qualified }; } - if (driver === "ssh" && environment && ["codex_local", "claude_local", "opencode_local"].some(harness => runnerAdaptersAvailable(harness, driver))) { + if (driver === "ssh" && environment && ["codex_local", "claude_local", "opencode_local", "pi_local"].some(harness => runnerAdaptersAvailable(harness, driver))) { const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable."); let output: string; diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index b35c86afe3c..bdadbe6c1c5 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -1762,7 +1762,7 @@ export function builtInAgentService(db: Db) { const adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig; assertAdapterAllowed(definition, adapterType, adapterConfig); const selectedHarness = agentHarnessType(adapterType, adapterConfig); - const sameQualifiedHarness = ["codex_local", "claude_local", "opencode_local"].includes(selectedHarness) + const sameQualifiedHarness = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedHarness) && agentHarnessType(existing.adapterType, existing.adapterConfig) === selectedHarness; const runner = resolvedInput.runner ?? (sameQualifiedHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true ? agentRunner(existing.adapterType) : undefined); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index e88eb0a9332..e6868da3827 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -5307,7 +5307,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { "OpenAI Dot is experimental and disabled on this instance.", { code: "paperclip_runner_dot_disabled" }, ); - } else if (config.provider !== "codex" && config.provider !== "opencode" && config.provider !== undefined && agentHarnessType("paperclip_runner", config) !== "claude_local" && experimental.enableNativeRunner !== true) { + } else if (config.provider !== "codex" && config.provider !== "opencode" && config.provider !== undefined && !["claude_local", "pi_local"].includes(agentHarnessType("paperclip_runner", config)) && experimental.enableNativeRunner !== true) { throw unprocessable( "Paperclip Runner is experimental and disabled on this instance.", { code: "paperclip_runner_rollout_disabled" }, @@ -5651,7 +5651,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { runner = agentRunner(existingImportAgent.adapterType); } } - const retainedNative = runner === "paperclip" && ["codex_local", "claude_local", "opencode_local"].includes(selectedHarness) + const retainedNative = runner === "paperclip" && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedHarness) ? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner" && agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness) : undefined; diff --git a/server/src/services/native-runtime/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index 8c4f02e48f3..381d77fc2da 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -54,7 +54,7 @@ describe("resolveNativeRuntimeMode", () => { expect(resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, })).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } }); - for (const acpxAgent of ["pi", "cursor", "codex"]) { + for (const acpxAgent of ["grok", "cursor", "codex"]) { expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent, model: "gpt-5.6-luna" }, })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled" })); @@ -96,16 +96,22 @@ describe("resolveNativeRuntimeMode", () => { })).toMatchObject({ kind: "native", profile: { backend: "opencode_server" } }); }); - it("still rejects Pi while the rollout flag is disabled", () => { + it("still rejects Grok while the rollout flag is disabled", () => { expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, - adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, + adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled", })); }); + it("admits Pi without the generic rollout flag", () => { + expect(resolveNativeRuntimeMode({ ...eligible, enabled: false, + adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/anthropic/claude-sonnet-4.6", piThinkingLevel: "max" }, + })).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } }); + }); + it("rejects unknown Paperclip Runner providers", () => { expect(() => resolveNativeRuntimeMode({ ...eligible, @@ -250,7 +256,7 @@ describe("resolveNativeRuntimeMode", () => { it("keeps a persisted experimental run native while the flag rejects a fresh start", () => { const disabled = { ...eligible, enabled: false, - adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, + adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, }; expect(resolveHeartbeatNativeRuntimeMode({ ...disabled, @@ -522,7 +528,7 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => { ...compatibilityInput, enabled: false, adapterType: "paperclip_runner", - adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/anthropic/claude-sonnet-4.6" }, + adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "openrouter/anthropic/claude-sonnet-4.6" }, })).toThrow(NativeRunnerSelectionError); }); it("admits native Claude without enabling other ACPX providers", () => { diff --git a/server/src/services/native-runtime/runtime-mode.ts b/server/src/services/native-runtime/runtime-mode.ts index e1c6fb24d42..5cd19debdde 100644 --- a/server/src/services/native-runtime/runtime-mode.ts +++ b/server/src/services/native-runtime/runtime-mode.ts @@ -140,7 +140,7 @@ export function resolveNativeRuntimeMode(input: { throw ineligible("paperclip_runner_grok_disabled", "Enable Grok Bot, Paperclip Runner, and Assistant connections (MCP) before assigning new work."); } else if (runnerProfile.provider !== "codex" && runnerProfile.provider !== "opencode" && !(runnerProfile.provider === "acpx" - && (runnerProfile.acpxAgent === "claude" || runnerProfile.acpxAgent === "copilot")) + && (["claude", "copilot", "pi"].includes(runnerProfile.acpxAgent ?? ""))) && !input.enabled) { throw ineligible( "paperclip_runner_rollout_disabled", diff --git a/ui/src/adapters/adapter-display-registry.test.ts b/ui/src/adapters/adapter-display-registry.test.ts index d2de92e6f8b..1ee731693ef 100644 --- a/ui/src/adapters/adapter-display-registry.test.ts +++ b/ui/src/adapters/adapter-display-registry.test.ts @@ -31,6 +31,7 @@ describe("adapter display registry", () => { expect(getAdapterLabel("hermes_gateway")).toBe("Hermes Gateway"); expect(getAdapterLabel("opencode_local")).toBe("OpenCode"); expect(getAdapterLabel("pi_local")).toBe("Pi"); + expect(agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi" } })).toBe("pi_local"); expect(getAdapterLabels()).toMatchObject({ codex_local: "Codex", diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index eb47db8e76c..c93a4e67dbe 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -222,7 +222,7 @@ export function agentDisplayAdapterType(agent: { adapterType: string; adapterCon return agent.adapterConfig.provider; } const harness = agentHarnessType(agent.adapterType, agent.adapterConfig); - return ["codex_local", "claude_local", "opencode_local"].includes(harness) ? harness : agent.adapterType; + return ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(harness) ? harness : agent.adapterType; } export function isKnownAdapterType(type: string): boolean { diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index b2d7006512d..f9498d9a23f 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -437,7 +437,7 @@ export function AgentActionButtons({ ) : ( { expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Claude" }); }); - it.each([{ provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, { provider: "unknown-provider" }])("preserves saved native $provider configuration with the experimental gate off", async adapterConfig => { + it.each([{ provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, { provider: "acpx", acpxAgent: "pi", model: "openrouter/anthropic/claude-sonnet-4.6", piThinkingLevel: "max", acpxPermissionMode: "approve-reads", lifecycleMode: "warm" }, { provider: "unknown-provider" }])("preserves saved native $provider configuration with the experimental gate off", async adapterConfig => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false }); const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig }, { showAdapterTypeField: true }); roots.push(result.root); - if (adapterConfig.provider === "opencode") { - expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain("OpenCode"); + if (adapterConfig.provider === "opencode" || adapterConfig.acpxAgent === "pi") { + expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain(adapterConfig.provider === "opencode" ? "OpenCode" : "Pi"); await clickByText(result.container, "Advanced"); expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain("Paperclip Runner"); } else expect(result.container.querySelector('[aria-label="Runner"]')).toBeNull(); + if (adapterConfig.acpxAgent === "pi") { + expect(result.container.querySelector('[aria-label="Pi thinking level"]')).not.toBeNull(); + expect(result.container.querySelector('[aria-label="Thinking effort"]')).toBeNull(); + } await act(async () => setInputValue(result.container.querySelector('[placeholder="Agent name"]')!, "Renamed native agent")); await flushReact(); await clickByText(result.container, "Save"); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 99eaffea9a9..140449f0b22 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1400,7 +1400,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : adapterType === "opencode_local" ? eff("adapterConfig", "variant", String(config.variant ?? "")) : eff("adapterConfig", thinkingEffortKey, String(config[thinkingEffortKey] ?? "")); - const showThinkingEffort = adapterType !== "gemini_local" + const showThinkingEffort = !(harnessType === "pi_local" && fieldsAdapterType === "paperclip_runner") + && adapterType !== "gemini_local" && adapterType !== "cursor_cloud" && (adapterType !== "paperclip_runner" || isRunnerHarness); const codexSearchEnabled = adapterType === "codex_local" @@ -1758,7 +1759,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { setOverlay((prev) => ({ ...prev, adapterType: t, - runner: ["codex_local", "claude_local", "opencode_local"].includes(t) ? "auto" : undefined, + runner: ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(t) ? "auto" : undefined, adapterConfig: { model: t === "gemini_local" diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx index ad609aff737..7121207404d 100644 --- a/ui/src/components/ConfigureBuiltInAgentModal.tsx +++ b/ui/src/components/ConfigureBuiltInAgentModal.tsx @@ -68,7 +68,7 @@ export function ConfigureBuiltInAgentModal({ const { definition } = state; const [adapterType, setAdapterType] = useState( - () => state.agent && ["codex_local", "claude_local", "opencode_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state), + () => state.agent && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state), ); const [runner, setRunner] = useState(); const environmentId = state.agent?.defaultEnvironmentId ?? null; @@ -143,7 +143,7 @@ export function ConfigureBuiltInAgentModal({ : { adapterType, adapterConfig }; const result = await builtInAgentsApi.provision(companyId, definition.key, { ...adapterInput, - ...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== undefined ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== undefined ? { runner } : {}), ...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}), }); return result; @@ -189,7 +189,7 @@ export function ConfigureBuiltInAgentModal({ /> - {["codex_local", "claude_local", "opencode_local"].includes(adapterType) &&
+ {["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) &&
Advanced { void adapters.refetch(); }} diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 24014dca2e2..1d53b817270 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -169,6 +169,7 @@ const copilotOnboardingQualified = PAPERCLIP_RUNNER_ACPX_PROFILES.some( ); function restoreOnboardingAdapterType(savedAdapterType: unknown, provider?: unknown, savedCopilot?: unknown): AdapterType { + if (savedAdapterType === "paperclip_runner" && provider === "pi") return "pi_local"; if (savedAdapterType === "paperclip_runner" && provider === "opencode") return "opencode_local"; if (savedAdapterType === "paperclip_runner" && savedCopilot !== true && (provider === undefined || provider === "codex")) return "codex_local"; return typeof savedAdapterType === "string" && (savedAdapterType !== "paperclip_runner" || (savedCopilot === true && copilotOnboardingQualified)) @@ -612,7 +613,7 @@ function OnboardingWizardInner({ restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider, saved?.onboardingCopilot), ); const [runner, setRunner] = useState(() => saved?.runner === "legacy" || saved?.runner === "paperclip" - ? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx", "opencode"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto"); + ? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx", "opencode", "pi"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto"); /** * Whether a model source has been chosen, as opposed to which one * `adapterType` happens to hold. @@ -642,7 +643,7 @@ function OnboardingWizardInner({ const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner" && !(saved?.onboardingCopilot === true && copilotOnboardingQualified); const [cwd, setCwd] = useState((saved?.cwd as string) ?? ""); const [model, setModel] = useState( - savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx", "opencode"].includes(String(saved.runnerProvider)) ? "" : (saved?.model as string) ?? "", + savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx", "opencode", "pi"].includes(String(saved.runnerProvider)) ? "" : (saved?.model as string) ?? "", ); const [command, setCommand] = useState( saved?.adapterType === "paperclip_runner" ? "" : (saved?.command as string) ?? "", @@ -1048,7 +1049,7 @@ function OnboardingWizardInner({ if (requestCopilotModels) void refreshAdapterModels(); }, [requestCopilotModels, copilotModelScope, refreshAdapterModels]); const copilotModelReady = Boolean(copilotConnection && copilotEnvironmentReady && !adapterModelsError && !adapterModelsFetching && adapterModels?.some((entry) => entry.id === model.trim())); - const needsRunnerDiscovery = ["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner === "auto"; + const needsRunnerDiscovery = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner === "auto"; const resolvedRunner = runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : runner; @@ -2032,7 +2033,7 @@ function OnboardingWizardInner({ ...defaultCreateValues, adapterType, runner, - ...(isCopilot ? { adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } } : {}), + ...(isCopilot ? { adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } } : adapterType === "pi_local" ? { adapterSchemaValues: savedConfig } : {}), resolvedRunner: setupRunner ?? resolvedRunner, model: adapterType === "gemini_local" @@ -2262,7 +2263,7 @@ function OnboardingWizardInner({ { adapterConfig: testedConfig, ...(restoredSetup?.companyId === createdCompanyId ? { agentId: restoredSetup.id } : {}), - ...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), ...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}), environmentId, } @@ -2524,7 +2525,7 @@ function OnboardingWizardInner({ ...(restoredSetup?.companyId === createdCompanyId ? { reportsTo: restoredSetup.reportsTo } : {}), adapterType, adapterConfig: hireAdapterConfig, - ...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), ...(shouldApplyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}), // The server owns what the first agent is told now: this marker seeds // the chief-of-staff persona over the agent's entry instruction file. @@ -3257,7 +3258,7 @@ function OnboardingWizardInner({ ) : null} - {sourceSelected && ["codex_local", "claude_local", "opencode_local"].includes(adapterType) &&
+ {sourceSelected && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) &&
Advanced { void runnerDiscovery.refetch(); }} diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index a026ca9606d..854b7743b79 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -28,7 +28,7 @@ import type { Agent, EnvBinding, } from "@paperclipai/shared"; -import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; +import { ADAPTER_AUTH_MISSING_CHECK_CODE, paperclipRunnerProfileForHarness } from "@paperclipai/shared"; import { useNavigate, useSearchParams } from "@/lib/router"; import { agentsApi } from "@/api/agents"; import { ApiError } from "@/api/client"; @@ -96,9 +96,9 @@ export function NewAgentSetup() { key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider") ?? "codex"}:${params.get("runner") ?? "auto"}`} companyId={selectedCompanyId} name={params.get("name") ?? ""} - adapterType={params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode"].includes(params.get("runnerProvider") ?? "codex") ? params.get("runnerProvider") === "claude" ? "claude_local" : params.get("runnerProvider") === "opencode" ? "opencode_local" : "codex_local" : params.get("adapterType") ?? ""} - initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : params.has("setupAgentId") && ["codex_local", "claude_local", "opencode_local"].includes(params.get("adapterType") ?? "") ? "legacy" : "auto")} - preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode"].includes(params.get("runnerProvider") ?? "codex")} + adapterType={params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode", "pi"].includes(params.get("runnerProvider") ?? "codex") ? params.get("runnerProvider") === "claude" ? "claude_local" : params.get("runnerProvider") === "opencode" ? "opencode_local" : params.get("runnerProvider") === "pi" ? "pi_local" : "codex_local" : params.get("adapterType") ?? ""} + initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode", "pi"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : params.has("setupAgentId") && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(params.get("adapterType") ?? "") ? "legacy" : "auto")} + preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode", "pi"].includes(params.get("runnerProvider") ?? "codex")} runnerProvider={params.get("runnerProvider") ?? "codex"} createdAgentId={params.get("createdAgentId")} setupAgentId={params.get("setupAgentId")} @@ -141,7 +141,7 @@ function Setup({ ? "claude_local" : runnerProvider === "opencode" ? "opencode_local" - : runnerProvider === "codex" ? "codex_local" : runnerProvider + : runnerProvider === "pi" ? "pi_local" : runnerProvider === "codex" ? "codex_local" : runnerProvider : adapterType; const connectionAdapter = brandType === "claude_local" || brandType === "codex_local" || brandType === "grok_local" @@ -163,7 +163,6 @@ function Setup({ createdAgentId ? "saved" : connectionAdapter ? "connect" : "runtime", ); const [model, setModel] = useState(""); - const efforts = isRunner ? [] : setupEfforts(adapterType, model); const [effort, setEffort] = useState(""); const [modelOpen, setModelOpen] = useState(false); const [environmentOverride, setEnvironmentOverride] = useState(""); @@ -175,7 +174,7 @@ function Setup({ const [runtimeAiBinding, setRuntimeAiBinding] = useState(() => brandType === "copilot_runtime" ? { provider: "github", method: "api_key", mode: "responsible_user" } - : brandType === "opencode_local" + : multiProvider ? { provider: "openrouter", method: "api_key", mode: "responsible_user" } : undefined, ); @@ -202,9 +201,9 @@ function Setup({ currentCreated ?? (savedAgent.data?.companyId === companyId ? savedAgent.data : null); const completed = Boolean(created && created.pauseReason !== "setup"); - const savedNativeOpenCode = brandType === "opencode_local" && (Boolean(setupAgentId) || runner === "legacy") + const savedNativeMultiProvider = multiProvider && (Boolean(setupAgentId) || runner === "legacy") && created?.adapterType === "paperclip_runner"; - const requestAdapterType = savedNativeOpenCode || (preserveNativeHarness && runner === "paperclip") + const requestAdapterType = savedNativeMultiProvider || (preserveNativeHarness && runner === "paperclip") ? "paperclip_runner" : adapterType; const [saving, setSaving] = useState(false); const [testState, setTestState] = useState("idle"); @@ -328,6 +327,8 @@ function Setup({ queryFn: () => adaptersApi.list({ companyId, environmentId }), }); const defaultRunner = adapters.data?.find(adapter => adapter.type === adapterType)?.defaultRunner; + const efforts = brandType === "pi_local" && (runner === "paperclip" || (runner === "auto" && defaultRunner === "paperclip")) + ? ["off", "low", "high", "max"] : isRunner ? [] : setupEfforts(adapterType, model); useEffect(() => { const transition = qualificationTransition.current; if (transition?.run === generation.current && transition.companyId === companyId @@ -385,7 +386,7 @@ function Setup({ ) && (requestAdapterType !== "paperclip_runner" || adapterType === "paperclip_runner" || adapterInventory.data?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled)); const connectionSettingsReady = Boolean( available && - (!["codex_local", "claude_local", "opencode_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) && + (!["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) && !environmentError && !envs.isPending && !settings.isPending && @@ -401,7 +402,7 @@ function Setup({ !caps.error && (!(managedOnly || forced.forced) || environmentId), ); - const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local"].includes(adapterType); + const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType); const ready = Boolean(created) && connectionSettingsReady && (!needsCurrentSetup || ( envs.isSuccess && !envs.isFetching && settings.isSuccess && !settings.isFetching @@ -429,32 +430,42 @@ function Setup({ ...(isRunner ? { adapterSchemaValues: { - provider: (runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? "acpx" : runnerProvider, + provider: ["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? "acpx" : runnerProvider, ...(isDot ? { allowUnmeteredProvider } : {}), - ...((runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? { acpxAgent: runnerProvider } : {}), + ...(["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}), }, } : {}), }; - const config = getUIAdapter(preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType).buildAdapterConfig(preserveNativeHarness && runner === "paperclip" ? { ...values, adapterSchemaValues: runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" } : { provider: runnerProvider } } : values); - if (preserveNativeHarness && runner === "paperclip") { - Object.assign(config, runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } : runnerProvider === "opencode" ? { provider: "opencode", opencodePermissionMode: "allow", lifecycleMode: "per_turn" } : { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" }); + const profile = paperclipRunnerProfileForHarness(brandType); + const nativeSchema = profile ? { + ...profile, + ...(profile.provider === "acpx" ? { acpxPermissionMode: "approve-all" } : {}), + ...(brandType === "pi_local" && effort ? { piThinkingLevel: effort } : {}), + } : {}; + const config = getUIAdapter(preserveNativeHarness && runner === "paperclip" && brandType !== "pi_local" ? "paperclip_runner" : adapterType).buildAdapterConfig(preserveNativeHarness && runner === "paperclip" ? { ...values, adapterSchemaValues: nativeSchema } : values); + if (preserveNativeHarness && runner === "paperclip" && profile) { + Object.assign(config, nativeSchema, profile.provider === "opencode" ? { opencodePermissionMode: "allow" } : profile.provider === "codex" ? { codexPermissionMode: "never" } : {}, { lifecycleMode: "per_turn" }); } if (isRunner) Object.assign(config, { - provider: (runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? "acpx" : runnerProvider, - ...((runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? { acpxAgent: runnerProvider } : {}), + provider: ["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? "acpx" : runnerProvider, + ...(["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}), ...(model ? { model } : {}), }); - if (brandType === "opencode_local" && created) { + if (multiProvider && created) { // The simplified setup page does not edit these saved runtime policies. const saved = created.adapterConfig; for (const [key, value] of Object.entries(saved)) { if (!(key in config)) config[key] = value; } - for (const key of ["dangerouslySkipPermissions", "opencodePermissionMode", "lifecycleMode", "idleTimeoutMs"]) { + for (const key of ["dangerouslySkipPermissions", "opencodePermissionMode", "acpxPermissionMode", "acpxSessionMode", "lifecycleMode", "idleTimeoutMs"]) { if (saved[key] !== undefined) config[key] = saved[key]; } + if (brandType === "pi_local" && created.adapterType === "paperclip_runner" && effort) { + config.piThinkingLevel = effort; + delete config.thinking; + } config.model = model; config.env = { ...((saved.env as object) ?? {}), ...((config.env as object) ?? {}) }; } @@ -544,8 +555,8 @@ function Setup({ role: existing.length ? "general" : "ceo", ...(leader ? { reportsTo: leader.id } : {}), adapterType: requestAdapterType, runner, adapterConfig: requestAdapterType === "paperclip_runner" ? { - provider: ["claude", "grok", "copilot"].includes(runnerProvider) ? "acpx" : runnerProvider, - ...(["claude", "grok", "copilot"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}), + provider: ["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? "acpx" : runnerProvider, + ...(["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}), } : {}, defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null), runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }), }).then(agent => { @@ -562,9 +573,9 @@ function Setup({ const agent = savedAgent.data; if (agent.pauseReason !== "setup") { setScreen("saved"); return; } const config = agent.adapterConfig; - if (brandType === "opencode_local") setRunner(agent.adapterType === "paperclip_runner" ? "paperclip" : "legacy"); + if (multiProvider) setRunner(agent.adapterType === "paperclip_runner" ? "paperclip" : "legacy"); setModel(typeof config.model === "string" ? config.model : ""); - setEffort(String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? config.thinkingEffort ?? "")); + setEffort(String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? config.thinkingEffort ?? config.piThinkingLevel ?? config.thinking ?? "")); setEnvironmentOverride(agent.defaultEnvironmentId ?? ""); setRepository(typeof config.repoUrl === "string" ? config.repoUrl : ""); setBranch(typeof config.repoStartingRef === "string" ? config.repoStartingRef : ""); @@ -662,7 +673,7 @@ function Setup({ companyId, adapterType: requestAdapterType, providerAdapter: brandType, - ...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), adapterConfig: config, testCredentials: pendingCredentials(nextConnection), aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection, @@ -717,7 +728,7 @@ function Setup({ role: created.role, reportsTo: created.reportsTo, adapterType: requestAdapterType, - ...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), adapterConfig: config, defaultEnvironmentId: environmentOverride || @@ -983,7 +994,7 @@ function Setup({ }).catch(cause => setError(cause instanceof Error ? cause.message : "Could not save agent setup.")); }} /> :

Loading connection settings…

} - {["codex_local", "claude_local", "opencode_local"].includes(adapterType) &&
+ {["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) &&
Advanced { void adapters.refetch(); }} @@ -1336,7 +1347,7 @@ function Setup({ )} - {["codex_local", "claude_local", "opencode_local"].includes(adapterType) &&
+ {["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) &&
Advanced { void adapters.refetch(); }} diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index cd714efa92c..be7a80b5e78 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -1227,6 +1227,26 @@ describe("CompanyImport", () => { expect(lastImportMeta().adapterOverrides?.coder).toBeUndefined(); }); + it("keeps native Pi identity and saved policies with the experimental flag off", async () => { + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false }, + { type: "pi_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }, + { type: "paperclip_runner", disabled: false }, + ]); + const preview = buildMixedAdapterPreviewResult(); + preview.manifest.agents[0]!.adapterType = "paperclip_runner"; + preview.manifest.agents[0]!.adapterConfig = { provider: "acpx", acpxAgent: "pi", model: "provider/model", piThinkingLevel: "max", acpxPermissionMode: "approve-reads", lifecycleMode: "warm" }; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + expect(findAdapterSelects()[0]!.value).toBe("pi_local"); + expect(container.textContent).not.toContain("requires its experimental feature to be enabled"); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides?.coder).toBeUndefined(); + }); + it.each([ { sourceType: "codex_local", sourceRunner: "legacy" as const, runner: "paperclip" as const, sourcePolicy: {}, expectedBypass: undefined }, { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: false }, expectedBypass: false }, diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 0f4c213be81..3c5d4f2f7e9 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -576,7 +576,7 @@ function importAdapterValues( sourceAdapterType?: string, runner?: AgentRunnerChoice, ): CreateConfigValues { - const nativeHarness = sourceAdapterType === "paperclip_runner" && ["codex_local", "claude_local", "opencode_local"].includes(choice); + const nativeHarness = sourceAdapterType === "paperclip_runner" && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(choice); const nativeCodex = nativeHarness && choice === "codex_local"; const savedBypass = sourceConfig?.dangerouslyBypassApprovalsAndSandbox ?? (nativeCodex ? undefined : sourceConfig?.dangerouslyBypassSandbox); @@ -594,6 +594,7 @@ function importAdapterValues( : resolveCodexLocalCliPolicy(sourceConfig).bypass, } : {}), ...(sourceConfig && choice === "opencode_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "" } : {}), + ...(sourceConfig && choice === "pi_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.piThinkingLevel ?? sourceConfig.thinking ?? "") } : {}), ...(sourceConfig && choice === "claude_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.modelReasoningEffort ?? sourceConfig.reasoningEffort ?? sourceConfig.effort ?? "") } : {}), ...(nativeHarness && sourceConfig ? { cwd: typeof sourceConfig.cwd === "string" ? sourceConfig.cwd : "", @@ -1683,7 +1684,7 @@ export function CompanyImport() { const nativeCodex = a.adapterType === "paperclip_runner" && (a.adapterConfig?.provider === "codex" || a.adapterConfig?.provider === undefined); const nativeHarness = nativeCodex || (a.adapterType === "paperclip_runner" - && ["claude_local", "opencode_local"].includes(agentHarnessType(a.adapterType, a.adapterConfig))); + && ["claude_local", "opencode_local", "pi_local"].includes(agentHarnessType(a.adapterType, a.adapterConfig))); const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot" ? "openai_dot" : a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "muse" ? "muse" : nativeHarness ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType; let fallbackAdapterType: string | null = null; @@ -1737,7 +1738,7 @@ export function CompanyImport() { if (selectedType === agent.adapterChoice && !adapterConfigEditedSlugs.has(agent.slug) && (!configVals || configVals.runner === importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, agent.runner).runner)) continue; const retainsNativeHarness = agent.adapterType === "paperclip_runner" - && selectedType === agent.adapterChoice && ["codex_local", "claude_local", "opencode_local"].includes(selectedType); + && selectedType === agent.adapterChoice && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedType); const apiType = selectedType === "openai_dot" || selectedType === "muse" || retainsNativeHarness ? "paperclip_runner" : selectedType; const override: CompanyPortabilityAdapterOverride = { adapterType: apiType }; if (configVals?.runner !== undefined) override.runner = configVals.runner; @@ -1758,7 +1759,7 @@ export function CompanyImport() { const build = getUIAdapter(nativeClaudeForm ? "paperclip_runner" : configVals.adapterType).buildAdapterConfig; const edited = build(configVals); if (agent.adapterType === "paperclip_runner" && selectedType === agent.adapterChoice - && ["codex_local", "claude_local", "opencode_local"].includes(selectedType) && configVals.adapterType === selectedType + && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedType) && configVals.adapterType === selectedType && agentHarnessType("paperclip_runner", configVals.adapterSchemaValues) === selectedType) { const original = importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, configVals.runner); const baseline = build(original);