Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/notes/implemented/feature/2026-09-17-builtin-pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 2 additions & 0 deletions .agents/notes/implemented/feature/2026-09-17-builtin-pi.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 构造约定、仅在不存在时持久化、既有自动注册覆盖、组件类型检查、翻译和文档检查。
13 changes: 7 additions & 6 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 7 additions & 6 deletions locales/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 重新发起分享请求。",
Expand Down Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions packages/components/src/atoms/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isCustomAcpLaunchSpec,
isLoroRepoDocDeleted,
machineFlockKeys,
parseMachineFlockRow,
findBuiltinAgentOptOutToRetract,
planBuiltinAgentOptOutForDeletedConfig,
readMachineFlockRowsFromFlock,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
56 changes: 28 additions & 28 deletions packages/components/src/components/chat/chat-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,9 @@ import {
FREE_SESSION_LIMIT_PER_WORKSPACE,
getServerNow,
isLegacyPiProvider,
migratePiProvider,
createManagedPiProvider,
machineSupportsProtocolCapability,
MACHINE_PROTOCOL_CAPABILITIES,
getMachineFlockDocId,
machineFlockKeys,
hashAnalyticsId,
type SessionStartFailureReason,
InFlightDedupe,
Expand Down Expand Up @@ -75,6 +73,7 @@ import {
bugReportDialogOpenAtom,
chatLandingSessionStateAtomFamily,
getAllAgentConfigAtom,
cmdCreateAgentConfigIfAbsentAtom,
inboxFeatureEnabledAtom,
mobileKeyboardActionAtom,
runtimeInitializingAtom,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -6076,49 +6076,49 @@ function WorkspaceChatLanding({
</div>
) : 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 {
setPiMigrationBusy(false);
}
};
const composerNoticeNode =
sharingReviewNoticeNode || sessionLimitNoticeNode || canMigratePi ? (
sharingReviewNoticeNode || sessionLimitNoticeNode || canAddManagedPi ? (
<>
<PiProviderMigrationCard
count={migratablePiProviders.length}
count={addablePiProviders.length}
busy={piMigrationBusy}
error={piMigrationError}
canMigrate={canMigratePi}
onConfirm={() => void confirmPiMigration()}
canMigrate={canAddManagedPi}
onConfirm={() => void addManagedPi()}
/>
{sharingReviewNoticeNode}
{sessionLimitNoticeNode}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,27 @@ export function PiProviderMigrationCard({
return (
<section
className="mb-3 rounded-lg border bg-background p-4 text-sm"
aria-label={t('chat.piMigration.title', 'Upgrade Pi providers')}
aria-label={t('chat.piMigration.title', 'Add managed Pi')}
>
<p className="font-medium">{t('chat.piMigration.title', 'Upgrade Pi providers')}</p>
<p className="font-medium">{t('chat.piMigration.title', 'Add managed Pi')}</p>
<p className="mt-1 text-muted-foreground">
{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.'
)}
</p>
{error && (
<p role="alert" className="mt-2 text-destructive">
{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.'
)}
</p>
)}
<Button className="mt-3" size="sm" disabled={busy || !canMigrate} onClick={onConfirm}>
{busy
? t('chat.piMigration.running', 'Upgrading…')
: t('chat.piMigration.confirm', 'Confirm upgrade')}
? t('chat.piMigration.running', 'Adding…')
: t('chat.piMigration.confirm', 'Add managed Pi')}
</Button>
</section>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2252,6 +2252,12 @@ export function AgentConfigDialog(props: AgentConfigDialogProps) {
'Custom providers are only probed when you click Test — re-test after changing the command.'
)}
</p>
<p className="text-[11px] leading-snug text-muted-foreground">
{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.'
)}
</p>
</div>
)}

Expand Down
Loading
Loading