diff --git a/.agents/notes/implemented/feature/2026-09-17-builtin-pi.md b/.agents/notes/implemented/feature/2026-09-17-builtin-pi.md
index ccf1e777c..6866008dd 100644
--- a/.agents/notes/implemented/feature/2026-09-17-builtin-pi.md
+++ b/.agents/notes/implemented/feature/2026-09-17-builtin-pi.md
@@ -45,3 +45,5 @@ Migration progress and retry feedback remain available for supported providers.
Focused launch, migration, authentication and protocol tests cover local behavior.
Packaging smoke uses the official CLI with a local synthetic model. It does not prove
commercial-provider quality or Windows runtime behavior on this macOS host.
+
+Follow-up: [side-by-side managed and self-managed Pi proposal](../../proposed/feature/2026-09-22-managed-and-self-managed-pi.md).
diff --git a/.agents/notes/implemented/feature/2026-09-17-builtin-pi.zh.md b/.agents/notes/implemented/feature/2026-09-17-builtin-pi.zh.md
index 6a4f080f6..0bb8af853 100644
--- a/.agents/notes/implemented/feature/2026-09-17-builtin-pi.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-17-builtin-pi.zh.md
@@ -35,3 +35,5 @@ Landing 按各 Provider 所属机器的 `builtinPi` 能力筛选旧 Provider。
定向启动、迁移、认证和协议测试覆盖本地行为。打包 smoke 使用官方 CLI 连接本地模拟
模型,不证明商业模型效果,也不能替代在 Windows 上验证运行时。
+
+后续:[托管与自管 Pi Provider 并存提案](../../proposed/feature/2026-09-22-managed-and-self-managed-pi.zh.md)。
diff --git a/.agents/notes/proposed/feature/2026-09-22-managed-and-self-managed-pi.md b/.agents/notes/proposed/feature/2026-09-22-managed-and-self-managed-pi.md
new file mode 100644
index 000000000..0f95779d0
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-22-managed-and-self-managed-pi.md
@@ -0,0 +1,53 @@
+# Managed and self-managed Pi providers side by side
+
+Status: proposed
+Translation: current
+
+[中文](2026-09-22-managed-and-self-managed-pi.zh.md)
+
+## Abstract
+
+The original Pi migration replaced a legacy `pi-acp` provider in place, which also
+replaced its launch behavior and left existing sessions bound to the new provider
+identity. This proposal adds a separate managed Pi provider on the selected machine
+and leaves the self-managed provider and its session bindings unchanged. A stable
+managed-provider ID makes retries idempotent, while separate Pi profiles remain an
+explicit user configuration rather than an isolation guarantee.
+
+## Proposed decision
+
+Replace the landing migration action with an add action. It creates one `builtin/pi`
+row for the selected machine only when that row is absent. It does not update the
+legacy row, copy its command or environment, switch the current provider selection,
+or rewrite Session metadata. Startup auto-registration continues to defer Pi while a
+legacy provider exists, so adding managed Pi remains an explicit choice.
+
+The managed row uses a deterministic per-machine ID. The write is insert-if-absent:
+repeated clicks, retries after an uncertain response, and concurrent clients converge
+on one row without overwriting later edits. An existing builtin Pi configuration wins.
+
+Custom Provider remains the recovery path for users who already lost their legacy
+row. Its existing command test verifies the configured ACP entry point. The UI explains
+that self-managed Pi needs an ACP-compatible adapter and that the plain `pi` command is
+not an ACP server.
+
+## Boundaries and trade-offs
+
+Provider IDs isolate launch configuration and Session bindings, but do not isolate
+Pi's profile directory. Users may set `PI_CODING_AGENT_DIR` on one provider; Lody does
+not move credentials or configuration and does not claim filesystem isolation. The
+managed runtime still cannot resume legacy `pi-acp` native session IDs.
+
+This proposal partially replaces the in-place migration decision recorded in
+[Managed Pi ACP and confirmed provider migration](../../implemented/feature/2026-09-17-builtin-pi.md).
+The runtime packaging, capability negotiation, and legacy session limits remain.
+
+## Evidence and verification plan
+
+- Issue: [LodyAI/Lody#832](https://github.com/LodyAI/Lody/issues/832)
+- Intent: [builtin Pi draft Spec](../../../../specs/builtin-pi.md)
+- Implementation: `packages/shared/src/pi-provider-migration.ts`,
+ `packages/components/src/atoms/agents.ts`, and
+ `packages/components/src/components/chat/chat-landing.tsx`
+- Verify the provider construction contract, insert-if-absent persistence, existing
+ auto-registration coverage, component type checks, localization, and docs checks.
diff --git a/.agents/notes/proposed/feature/2026-09-22-managed-and-self-managed-pi.zh.md b/.agents/notes/proposed/feature/2026-09-22-managed-and-self-managed-pi.zh.md
new file mode 100644
index 000000000..feace5029
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-09-22-managed-and-self-managed-pi.zh.md
@@ -0,0 +1,44 @@
+# 托管与自管 Pi Provider 并存
+
+Status: proposed
+Translation: current
+
+[English](2026-09-22-managed-and-self-managed-pi.md)
+
+## 摘要
+
+原有 Pi 迁移会原地替换旧 `pi-acp` Provider,同时改变其启动行为,并让既有会话继续
+绑定到已经改变含义的 Provider 身份。本提案改为在用户选中的机器上新增独立的托管 Pi,
+保留自管 Provider 及其会话绑定。稳定的托管 Provider ID 让重试保持幂等;不同 Pi
+profile 仍由用户显式配置,不把它描述为自动隔离保证。
+
+## 提议决策
+
+将 landing 的迁移动作改为新增动作。仅当目标行不存在时,才在当前选中的机器上创建一个
+`builtin/pi` 行;不更新旧 Provider,不复制其命令或环境变量,不切换当前 Provider,
+也不改写 Session 元数据。只要旧 Provider 仍存在,启动时的自动注册就继续跳过 Pi,
+因此新增托管 Pi 始终是用户的显式选择。
+
+托管行使用每机器确定性 ID,并采用“仅在不存在时写入”。重复点击、结果不确定后的重试和
+并发客户端都会收敛到同一行,且不会覆盖之后的用户编辑;已有的内置 Pi 配置优先保留。
+
+对于已经丢失旧 Provider 的用户,Custom Provider 仍是恢复路径,并复用已有命令测试来
+验证 ACP 入口。界面明确说明:自管 Pi 需要兼容 ACP 的适配器,普通 `pi` 命令不是 ACP 服务。
+
+## 边界与取舍
+
+Provider ID 隔离启动配置和 Session 绑定,但不隔离 Pi profile 目录。用户可以在其中一个
+Provider 上设置 `PI_CODING_AGENT_DIR`;Lody 不移动凭据或配置文件,也不承诺文件系统隔离。
+托管运行时仍无法恢复旧 `pi-acp` 的原生会话 ID。
+
+本提案部分替代[托管 Pi ACP 与确认式 Provider 迁移](../../implemented/feature/2026-09-17-builtin-pi.zh.md)
+中的原地迁移决策;运行时打包、能力协商和旧会话限制保持不变。
+
+## 证据与验证计划
+
+- Issue:[LodyAI/Lody#832](https://github.com/LodyAI/Lody/issues/832)
+- 意图:[内置 Pi 草案 Spec](../../../../specs/builtin-pi.zh.md)
+- 实现:`packages/shared/src/pi-provider-migration.ts`、
+ `packages/components/src/atoms/agents.ts` 和
+ `packages/components/src/components/chat/chat-landing.tsx`
+- 验证 Provider 构造约定、仅在不存在时持久化、既有自动注册覆盖、组件类型检查、翻译和文档检查。
diff --git a/locales/en.json b/locales/en.json
index 952f0fab0..47622273a 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -7,12 +7,12 @@
"sharing.request.deny": "Do not share",
"sharing.request.approve": "Approve and share",
"settings.agent.dialog.option.pi.description": "Lody-managed Pi ACP runtime",
- "chat.piMigration.title": "Upgrade Pi providers",
- "chat.piMigration.description": "Switch your existing Pi providers to the built-in version managed by Lody. Names, credentials and settings are kept. Start a new chat after upgrading; old Pi sessions cannot be resumed by the new adapter.",
- "chat.piMigration.upgradeMachine": "Update Lody on the providers’ machines before migrating.",
- "chat.piMigration.error": "Some providers could not be upgraded. Your remaining providers are unchanged. Try again.",
- "chat.piMigration.running": "Upgrading…",
- "chat.piMigration.confirm": "Confirm upgrade",
+ "chat.piMigration.title": "Add managed Pi",
+ "chat.piMigration.description": "Add Pi managed by Lody alongside your existing self-managed Pi provider. The existing provider, environment, and sessions stay unchanged. Use PI_CODING_AGENT_DIR on a provider when the two integrations need separate Pi profiles.",
+ "chat.piMigration.upgradeMachine": "Update Lody on the selected machine before adding managed Pi.",
+ "chat.piMigration.error": "Managed Pi could not be added. Your existing provider is unchanged. Try again.",
+ "chat.piMigration.running": "Adding…",
+ "chat.piMigration.confirm": "Add managed Pi",
"sharing.toggleTree": "Toggle conversation tree",
"sharing.fileAttachmentOmitted": "File attachment not included in this share",
"sharing.static.attachmentNotice": "Images are also shared. File attachments are not included.",
@@ -2550,6 +2550,7 @@
"settings.agent.dialog.back": "Back to type list",
"settings.agent.dialog.chooseType": "Choose a type",
"settings.agent.dialog.custom.testHint": "Custom providers are only probed when you click Test — re-test after changing the command.",
+ "settings.agent.dialog.custom.piHint": "For a self-managed Pi provider, enter the command for an ACP-compatible adapter. The plain pi command is not an ACP server.",
"settings.agent.dialog.custom.testing": "Testing…",
"settings.agent.dialog.custom.test": "Test command",
"settings.agent.dialog.custom.commandPlaceholder": "npx -y my-acp-agent",
diff --git a/locales/zh_CN.json b/locales/zh_CN.json
index 209cec84c..0445e7cbb 100644
--- a/locales/zh_CN.json
+++ b/locales/zh_CN.json
@@ -7,12 +7,12 @@
"sharing.request.deny": "不分享",
"sharing.request.approve": "批准并分享",
"settings.agent.dialog.option.pi.description": "由 Lody 管理的 Pi ACP 运行时",
- "chat.piMigration.title": "升级 Pi Provider",
- "chat.piMigration.description": "将已有 Pi Provider 切换为 Lody 管理的内置版本,保留名称、凭据和设置。升级后请新建对话;新适配器无法直接续聊旧 Pi 会话。",
- "chat.piMigration.upgradeMachine": "请先更新这些 Provider 所属机器上的 Lody,再进行迁移。",
- "chat.piMigration.error": "部分 Provider 升级失败,剩余 Provider 保持原样,请重试。",
- "chat.piMigration.running": "正在升级…",
- "chat.piMigration.confirm": "确认升级",
+ "chat.piMigration.title": "添加托管 Pi",
+ "chat.piMigration.description": "在现有自管 Pi Provider 旁新增由 Lody 管理的 Pi;原 Provider、环境变量和会话保持不变。若两种接入需要独立 Pi profile,可在其中一个 Provider 上设置 PI_CODING_AGENT_DIR。",
+ "chat.piMigration.upgradeMachine": "请先更新当前所选机器上的 Lody,再添加托管 Pi。",
+ "chat.piMigration.error": "无法添加托管 Pi;现有 Provider 保持不变,请重试。",
+ "chat.piMigration.running": "正在添加…",
+ "chat.piMigration.confirm": "添加托管 Pi",
"sharing.toggleTree": "展开或收起对话树",
"sharing.fileAttachmentOmitted": "文件附件未包含在此次分享中",
"sharing.request.incomplete": "此部署尚未发布,编辑器关闭后无法续传。请先放弃部署,再让 Agent 使用新的 requestId 重新发起分享请求。",
@@ -2550,6 +2550,7 @@
"settings.agent.dialog.back": "返回类型列表",
"settings.agent.dialog.chooseType": "选择类型",
"settings.agent.dialog.custom.testHint": "自定义 provider 仅在点击「测试命令」时探测——修改命令后需要重新测试。",
+ "settings.agent.dialog.custom.piHint": "如需自管 Pi Provider,请填写兼容 ACP 的适配器启动命令;普通 pi 命令不是 ACP 服务。",
"settings.agent.dialog.custom.testing": "测试中…",
"settings.agent.dialog.custom.test": "测试命令",
"settings.agent.dialog.custom.commandPlaceholder": "npx -y my-acp-agent",
diff --git a/packages/components/src/atoms/agents.ts b/packages/components/src/atoms/agents.ts
index b224964c5..0f5193d04 100644
--- a/packages/components/src/atoms/agents.ts
+++ b/packages/components/src/atoms/agents.ts
@@ -12,6 +12,7 @@ import {
isCustomAcpLaunchSpec,
isLoroRepoDocDeleted,
machineFlockKeys,
+ parseMachineFlockRow,
findBuiltinAgentOptOutToRetract,
planBuiltinAgentOptOutForDeletedConfig,
readMachineFlockRowsFromFlock,
@@ -67,6 +68,39 @@ export async function writeAgentConfigToMachineFlock(
return rows;
}
+export async function writeAgentConfigToMachineFlockIfAbsent(
+ runtime: WorkspaceRuntime,
+ config: AgentConfigMeta
+): Promise<{ inserted: boolean; rows: MachineFlockRowMap }> {
+ const flockDocId = getMachineFlockDocId(runtime.workspaceId, config.machineId);
+ const key = machineFlockKeys.agentConfig(config.id);
+ const result = await runtime.writer.flockRowPutIfAbsent(flockDocId, key, config);
+ const storedRow = parseMachineFlockRow(key, result.value);
+ const storedConfig =
+ storedRow?.key[0] === 'agentConfig' ? (storedRow.value as AgentConfigMeta) : undefined;
+ if (
+ !storedConfig ||
+ storedConfig.id !== config.id ||
+ storedConfig.machineId !== config.machineId
+ ) {
+ throw new Error(`Existing agent config row is invalid: ${config.id}`);
+ }
+
+ const handle = await runtime.repo.openFlockDoc(flockDocId);
+ const rows: MachineFlockRowMap = {
+ ...readMachineFlockRowsFromFlock(handle.flock),
+ [serializeMachineFlockKey(key)]: { key, value: storedConfig },
+ };
+ if (result.inserted) {
+ const optOutKey = findBuiltinAgentOptOutToRetract(rows, config);
+ if (optOutKey) {
+ await runtime.writer.flockRowDelete(flockDocId, optOutKey);
+ delete rows[serializeMachineFlockKey(optOutKey)];
+ }
+ }
+ return { inserted: result.inserted, rows };
+}
+
async function deleteAgentConfigFromMachineFlock(
runtime: WorkspaceRuntime,
config: AgentConfigMeta
@@ -373,6 +407,22 @@ export const cmdCreateAgentConfigAtom = atom(
}
);
+export const cmdCreateAgentConfigIfAbsentAtom = atom(
+ null,
+ async (get, _set, config: CreateAgentConfigInput) => {
+ const runtime = get(activeWorkspaceRuntimeAtom);
+ if (!runtime) throw new Error('Runtime not ready');
+ if (!config.machineId) throw new Error('machineId is required to create an agent config');
+ const result = await writeAgentConfigToMachineFlockIfAbsent(runtime, config);
+ _set(setMachineFlockRowsForMachineAtom, {
+ workspaceId: runtime.workspaceId,
+ machineId: config.machineId,
+ rows: result.rows,
+ });
+ return { id: config.id, inserted: result.inserted };
+ }
+);
+
export const cmdCreateProviderSetupAtom = atom(null, async (get, set, config: AgentConfigMeta) => {
const runtime = get(activeWorkspaceRuntimeAtom);
if (!runtime) throw new Error('Runtime not ready');
diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx
index 833baf0d5..c7fa5b83e 100644
--- a/packages/components/src/components/chat/chat-landing.tsx
+++ b/packages/components/src/components/chat/chat-landing.tsx
@@ -23,11 +23,9 @@ import {
FREE_SESSION_LIMIT_PER_WORKSPACE,
getServerNow,
isLegacyPiProvider,
- migratePiProvider,
+ createManagedPiProvider,
machineSupportsProtocolCapability,
MACHINE_PROTOCOL_CAPABILITIES,
- getMachineFlockDocId,
- machineFlockKeys,
hashAnalyticsId,
type SessionStartFailureReason,
InFlightDedupe,
@@ -75,6 +73,7 @@ import {
bugReportDialogOpenAtom,
chatLandingSessionStateAtomFamily,
getAllAgentConfigAtom,
+ cmdCreateAgentConfigIfAbsentAtom,
inboxFeatureEnabledAtom,
mobileKeyboardActionAtom,
runtimeInitializingAtom,
@@ -578,6 +577,7 @@ function WorkspaceChatLanding({
const navigate = useNavigate();
const { openSettings } = useOpenSettings();
const runtime = useAtomValue(activeWorkspaceRuntimeAtom);
+ const createAgentConfigIfAbsent = useSetAtom(cmdCreateAgentConfigIfAbsentAtom);
const workspaceRuntime = useAtomValue(runtimeAtom);
const postHog = usePostHog();
const multiWorkspaceAvailable = useAppCapability('multiWorkspace');
@@ -6076,34 +6076,34 @@ function WorkspaceChatLanding({
) : null;
const legacyPiProviders = executorConfigs.filter(
- (config) => isLegacyPiProvider(config) && isOwnVisibleMachine(config.machineId)
+ (config) =>
+ isLegacyPiProvider(config) &&
+ config.machineId === selectedMachineId &&
+ isOwnVisibleMachine(config.machineId)
);
const [piMigrationBusy, setPiMigrationBusy] = useState(false);
const [piMigrationError, setPiMigrationError] = useState(false);
- const migratablePiProviders = legacyPiProviders.filter((config) =>
- machineSupportsProtocolCapability(
- machines.get(config.machineId),
- MACHINE_PROTOCOL_CAPABILITIES.builtinPi
- )
+ const addablePiProviders = legacyPiProviders.filter(
+ (config) =>
+ machineSupportsProtocolCapability(
+ machines.get(config.machineId),
+ MACHINE_PROTOCOL_CAPABILITIES.builtinPi
+ ) &&
+ !executorConfigs.some(
+ (candidate) =>
+ candidate.machineId === config.machineId &&
+ candidate.cliType === 'builtin' &&
+ candidate.agentType === 'pi'
+ )
);
- const canMigratePi = migratablePiProviders.length > 0;
- const confirmPiMigration = async () => {
- if (!runtime || piMigrationBusy || !canMigratePi) return;
+ const canAddManagedPi = addablePiProviders.length > 0;
+ const addManagedPi = async () => {
+ if (piMigrationBusy || !canAddManagedPi) return;
setPiMigrationBusy(true);
setPiMigrationError(false);
try {
- for (const config of migratablePiProviders) {
- await runtime.writer.flockRowUpdate(
- getMachineFlockDocId(runtime.workspaceId, config.machineId),
- machineFlockKeys.agentConfig(config.id),
- (current) =>
- isLegacyPiProvider(current) &&
- current.id === config.id &&
- current.machineId === config.machineId
- ? migratePiProvider(current)
- : undefined
- );
- }
+ const managed = createManagedPiProvider(addablePiProviders[0]);
+ if (managed) await createAgentConfigIfAbsent(managed);
} catch {
setPiMigrationError(true);
} finally {
@@ -6111,14 +6111,14 @@ function WorkspaceChatLanding({
}
};
const composerNoticeNode =
- sharingReviewNoticeNode || sessionLimitNoticeNode || canMigratePi ? (
+ sharingReviewNoticeNode || sessionLimitNoticeNode || canAddManagedPi ? (
<>
{t('chat.piMigration.title', 'Upgrade Pi providers')} {t('chat.piMigration.title', 'Add managed Pi')}
{t(
'chat.piMigration.description',
- 'Switch your existing Pi providers to the built-in version managed by Lody. Names, credentials and settings are kept. Start a new chat after upgrading; old Pi sessions cannot be resumed by the new adapter.'
+ 'Add Pi managed by Lody alongside your existing self-managed Pi provider. The existing provider, environment, and sessions stay unchanged. Use PI_CODING_AGENT_DIR on a provider when the two integrations need separate Pi profiles.'
)}
{t(
'chat.piMigration.error',
- 'Some providers could not be upgraded. Your remaining providers are unchanged. Try again.'
+ 'Managed Pi could not be added. Your existing provider is unchanged. Try again.'
)}
+ {t( + 'settings.agent.dialog.custom.piHint', + 'For a self-managed Pi provider, enter the command for an ACP-compatible adapter. The plain pi command is not an ACP server.' + )} +
)} diff --git a/packages/components/tests/agent-config-create.test.ts b/packages/components/tests/agent-config-create.test.ts index 0003c2b99..bfd1931fc 100644 --- a/packages/components/tests/agent-config-create.test.ts +++ b/packages/components/tests/agent-config-create.test.ts @@ -10,7 +10,11 @@ import { type WorkspaceId, } from '@lody/shared'; -import { cmdCreateAgentConfigAtom, getAllAgentConfigAtom } from '../src/atoms/agents'; +import { + cmdCreateAgentConfigAtom, + cmdCreateAgentConfigIfAbsentAtom, + getAllAgentConfigAtom, +} from '../src/atoms/agents'; import { runtimeAtom, type WorkspaceRuntime } from '../src/atoms/runtime'; import { currentWorkspaceIdAtom, currentWorkspaceSlugAtom } from '../src/atoms/workspace-context'; @@ -108,3 +112,55 @@ describe('cmdCreateAgentConfigAtom', () => { ]); }); }); + +describe('cmdCreateAgentConfigIfAbsentAtom', () => { + it('keeps an existing row unchanged when an idempotent create is retried', async () => { + const store = createStore(); + const workspaceId = 'workspace-agent-config-create-if-absent' as WorkspaceId; + const workspaceSlug = 'workspace-agent-config-create-if-absent'; + const machineId = 'machine-agent-config-create-if-absent' as MachineId; + const configId = 'builtin-pi:machine-agent-config-create-if-absent' as AgentConfigId; + const key = machineFlockKeys.agentConfig(configId); + const existing = { + id: configId, + machineId, + name: 'My managed Pi', + description: undefined, + cliType: 'builtin' as const, + agentType: 'pi', + env: { PI_CODING_AGENT_DIR: '/synthetic/profile' }, + }; + const rows = new Map