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 ? ( <> void confirmPiMigration()} + canMigrate={canAddManagedPi} + onConfirm={() => void addManagedPi()} /> {sharingReviewNoticeNode} {sessionLimitNoticeNode} diff --git a/packages/components/src/components/chat/pi-provider-migration-card.tsx b/packages/components/src/components/chat/pi-provider-migration-card.tsx index 76b2880c9..b4b6eb7c1 100644 --- a/packages/components/src/components/chat/pi-provider-migration-card.tsx +++ b/packages/components/src/components/chat/pi-provider-migration-card.tsx @@ -19,27 +19,27 @@ export function PiProviderMigrationCard({ return (
-

{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.' )}

{error && (

{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.' )}

)}
); diff --git a/packages/components/src/components/settings/agent-config-dialog.tsx b/packages/components/src/components/settings/agent-config-dialog.tsx index a1e045e38..673975a83 100644 --- a/packages/components/src/components/settings/agent-config-dialog.tsx +++ b/packages/components/src/components/settings/agent-config-dialog.tsx @@ -2252,6 +2252,12 @@ export function AgentConfigDialog(props: AgentConfigDialogProps) { 'Custom providers are only probed when you click Test — re-test after changing the command.' )}

+

+ {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([ + [serializeMachineFlockKey(key), { key, value: existing }], + ]); + const openFlockDoc = vi.fn(async () => ({ + flock: { scan: vi.fn(() => rows.values()) }, + syncOnce: vi.fn(() => never()), + joinRoom: vi.fn(), + })); + const flockRowPutIfAbsent = vi.fn(async () => ({ inserted: false, value: existing })); + + store.set(runtimeAtom, { + workspaceId, + workspaceSlug, + repo: { openFlockDoc, flush: vi.fn(() => never()) }, + writer: { flockRowPutIfAbsent } as unknown as WorkspaceRuntime['writer'], + } as unknown as WorkspaceRuntime); + store.set(currentWorkspaceIdAtom, workspaceId); + store.set(currentWorkspaceSlugAtom, workspaceSlug); + + const result = await store.set(cmdCreateAgentConfigIfAbsentAtom, { + ...existing, + name: 'Pi', + env: {}, + }); + + expect(result).toEqual({ id: configId, inserted: false }); + expect(flockRowPutIfAbsent).toHaveBeenCalledWith( + `${workspaceId}:mf:${machineId}`, + key, + expect.objectContaining({ id: configId, name: 'Pi', env: {} }) + ); + expect(store.get(getAllAgentConfigAtom)).toEqual([existing]); + }); +}); diff --git a/packages/shared/src/pi-provider-migration.ts b/packages/shared/src/pi-provider-migration.ts index 1437bd431..a2b0379ab 100644 --- a/packages/shared/src/pi-provider-migration.ts +++ b/packages/shared/src/pi-provider-migration.ts @@ -1,4 +1,5 @@ import type { AgentConfigMeta } from './schema'; +import type { AgentConfigId, MachineId } from './ids'; /** Only the known registry identity is eligible, never a similarly named custom command. */ export function isLegacyPiProvider(value: unknown): value is AgentConfigMeta { @@ -12,7 +13,21 @@ export function isLegacyPiProvider(value: unknown): value is AgentConfigMeta { ); } -/** Run inside flockRowUpdate against the live row; retries must not recreate a deleted provider. */ -export function migratePiProvider(value: unknown): AgentConfigMeta | undefined { - return isLegacyPiProvider(value) ? { ...value, cliType: 'builtin', agentType: 'pi' } : undefined; +/** Stable identity makes retries add the same managed provider instead of duplicates. */ +export function getManagedPiProviderId(machineId: MachineId): AgentConfigId { + return `builtin-pi:${machineId}` as AgentConfigId; +} + +/** Build a managed Pi provider beside the legacy provider without copying its launch environment. */ +export function createManagedPiProvider(value: unknown): AgentConfigMeta | undefined { + if (!isLegacyPiProvider(value)) return undefined; + return { + id: getManagedPiProviderId(value.machineId), + machineId: value.machineId, + name: 'Pi', + description: undefined, + cliType: 'builtin', + agentType: 'pi', + env: {}, + }; } diff --git a/packages/shared/tests/pi-provider-migration.test.ts b/packages/shared/tests/pi-provider-migration.test.ts index ef59373ab..db208bf70 100644 --- a/packages/shared/tests/pi-provider-migration.test.ts +++ b/packages/shared/tests/pi-provider-migration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isLegacyPiProvider, migratePiProvider } from '../src/pi-provider-migration'; +import { createManagedPiProvider, isLegacyPiProvider } from '../src/pi-provider-migration'; describe('Pi provider migration', () => { const legacy = { @@ -14,11 +14,21 @@ describe('Pi provider migration', () => { futureField: 'preserved', }; - it('preserves identity, credentials and unknown configuration fields', () => { - const upgraded = migratePiProvider(legacy); - expect(upgraded).toEqual({ ...legacy, cliType: 'builtin', agentType: 'pi' }); + it('creates a separate managed provider without changing the self-managed provider', () => { + const managed = createManagedPiProvider(legacy); + expect(managed).toEqual({ + id: 'builtin-pi:machine', + machineId: 'machine', + name: 'Pi', + description: undefined, + cliType: 'builtin', + agentType: 'pi', + env: {}, + }); expect(legacy.cliType).toBe('registry'); - expect(migratePiProvider(upgraded)).toBeUndefined(); + expect(legacy.agentType).toBe('pi-acp'); + expect(legacy.env).toEqual({ API_KEY: 'synthetic-secret' }); + expect(createManagedPiProvider(managed)).toBeUndefined(); }); it('does not recreate deleted rows or overwrite a changed provider', () => { @@ -30,7 +40,11 @@ describe('Pi provider migration', () => { { ...legacy, agentType: 'pi' }, ]) { expect(isLegacyPiProvider(value)).toBe(false); - expect(migratePiProvider(value)).toBeUndefined(); + expect(createManagedPiProvider(value)).toBeUndefined(); } }); + + it('uses one managed provider identity per machine across retries', () => { + expect(createManagedPiProvider(legacy)?.id).toBe(createManagedPiProvider(legacy)?.id); + }); }); diff --git a/specs/builtin-pi.md b/specs/builtin-pi.md index 30cf8b053..35f3eb2f3 100644 --- a/specs/builtin-pi.md +++ b/specs/builtin-pi.md @@ -21,24 +21,24 @@ failed runtime and continue with the others. Cache errors must not abort daemon startup; actual launch and installation keep strict validation. Registry generation excludes `pi-acp` from new-provider discovery. Existing registry -providers remain runnable until their owner explicitly confirms the chat landing card. -Startup must not auto-create builtin Pi while the same machine has a legacy Pi -provider; after confirmed migration the existing same-ID builtin row is reused. -Migration requires a target daemon advertising `builtinPi` version 1, changes the -same provider row to `builtin/pi`, and preserves its ID, machine, name, environment, -prompt and other fields. Deleted or already changed rows are not recreated or overwritten. -Partial failures can be retried without remigrating completed rows. +providers remain runnable. Startup must not auto-create builtin Pi while the same +machine has a legacy Pi provider. The chat landing action adds a separate `builtin/pi` +provider on the machine the user selected and leaves the legacy provider's identity, +launch configuration, environment, and existing Session bindings unchanged. Its stable +managed-provider ID makes retries idempotent; if that machine already has a builtin Pi +provider, the action must not create another or change the current default selection. +The managed provider does not inherit the self-managed provider's environment. The daemon advertises `builtinPi` only when its Node version and platform satisfy the pinned runtime manifest; an incompatible host must not offer migration. -The landing card appears when at least one eligible provider's machine advertises -that capability. Confirmation migrates only providers on supported machines; -unsupported machines do not block them and their providers remain unchanged until -a later confirmation after those machines become supported. Hide the card when no -providers can migrate. Once migration starts, the card may show disabled progress feedback. - -This is provider migration, not native session conversion. The new adapter accepts -native Pi JSONL paths and cannot resume legacy `pi-acp` IDs. The confirmation card -states that users should start a new chat; stored conversation history is not rewritten. +The landing card appears when the selected machine has a legacy provider, advertises +that capability, and has no builtin Pi provider. Other machines and unsupported machines +remain unchanged. Once creation starts, the card may show disabled progress feedback. + +Separate provider IDs isolate integration settings, not the Pi profile itself. Users +who need separate profiles can set `PI_CODING_AGENT_DIR` on one provider; Lody does not +move credentials or configuration files, and an independent profile may need its own +authentication and model configuration. Managed Pi still cannot resume legacy +`pi-acp` IDs, and stored conversation history is not rewritten. ## Evidence diff --git a/specs/builtin-pi.zh.md b/specs/builtin-pi.zh.md index b9c91ed13..b701a7684 100644 --- a/specs/builtin-pi.zh.md +++ b/specs/builtin-pi.zh.md @@ -17,20 +17,20 @@ Windows 原生模块必须来自同一提交的成功构建。凭据继续由执 启动缓存清理和后台更新扫描按运行时隔离错误:记录警告,跳过失败项并继续 处理其他运行时。缓存错误不得导致 daemon 启动失败;实际启动和安装仍严格校验。 -Registry 生成时排除 `pi-acp`,不再提供新建入口。已有 registry Provider 在其所有者 -明确确认 chat landing 卡片之前仍可启动。同一机器存在旧 Pi Provider 时,启动不得自动 -创建 builtin Pi;确认迁移后复用相同 ID 的 builtin 行。 +Registry 生成时排除 `pi-acp`,不再提供新建入口,已有 registry Provider +继续可用。同一机器存在旧 Pi Provider 时,启动不得自动创建 builtin Pi。 +chat landing 动作只在用户当前选中的机器上新增独立的 `builtin/pi` Provider, +并保持旧 Provider 的身份、启动配置、环境变量和既有 Session 绑定不变。 +稳定的内置 Provider ID 使重试保持幂等;若该机器已有内置 Pi,不得重复创建 +或改变当前默认选择。内置 Provider 不继承自管 Provider 的环境变量。 daemon 仅在 Node 版本和平台满足固定运行时要求时 -声明 `builtinPi`,不兼容的机器不得允许迁移。迁移要求目标 daemon 声明 `builtinPi` v1, -在原 Provider 行上改为 `builtin/pi`,保留 ID、机器、名称、环境、提示词及其他字段。 -已删除或已切换类型的行不会被恢复或覆盖。部分失败可以重试,已完成的行不会重复迁移。 -只要至少一个待迁移 Provider 所属机器声明上述能力,landing 就显示升级卡片。 -确认时只迁移支持升级的机器上的 Provider;其他机器不阻塞迁移,其 Provider 保持不变, -等机器支持升级后再次确认迁移。没有可迁移 Provider 时隐藏卡片。 -迁移开始后,卡片可以保留按钮禁用的进行中反馈。 - -这是 Provider 迁移,不是原生会话转换。新适配器接受 Pi 原生 JSONL 路径,不能恢复 -旧 `pi-acp` ID。确认卡片要求升级后新建对话,已有对话历史不被改写。 +声明 `builtinPi`,不兼容的机器不得允许新增。仅当当前选中机器存在旧 Provider、 +声明该能力且尚无内置 Pi 时显示 landing 卡片。其他机器和不支持的机器保持不变。 +新增开始后,卡片可以保留按钮禁用的进行中反馈。 + +独立 Provider ID 只隔离接入设置,不会自动隔离 Pi profile。需要独立 profile 时, +用户可在其中一个 Provider 上设置 `PI_CODING_AGENT_DIR`;Lody 不移动凭据或配置文件, +独立 profile 可能需要重新配置认证和模型。内置 Pi 仍不能恢复旧 `pi-acp` ID,已有对话历史不被改写。 ## 实现依据