diff --git a/.gitignore b/.gitignore index 34be0c9bca..eba8d344ef 100755 --- a/.gitignore +++ b/.gitignore @@ -128,10 +128,6 @@ dev-debug.log *.sw? # OS specific -# Task files -tasks.json -tasks/ - # Translations !src/i18n/locales/en/tasks.json !src/i18n/locales/fr/tasks.json @@ -151,3 +147,10 @@ tasks/ cloudcli-sidebar-app-source.tar.gz cloudcli-sidebar.html electron/*.tar.gz + +# Task files +# tasks.json +# tasks/ + +.playwright-mcp/ +paseo.json diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..1523980255 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# Collaboration Domain + +This context defines how employees in one company collaborate on CLI projects and sessions while retaining explicit access boundaries and accountability. + +## Language + +**Organization**: +The single company boundary whose Users collaborate within this deployment. +_Avoid_: Tenant, customer account, workspace + +**User**: +An authenticated employee who belongs to the Organization. +_Avoid_: Account, tenant user + +**Corporate Identity**: +The authoritative employee identity for a User, supplied by the Organization's Feishu tenant. +_Avoid_: Local account, application password + +**Project**: +A company codebase or working directory that authorized Users collaborate on. +_Avoid_: Personal project, workspace + +**Project Membership**: +The relationship that grants a User access to a Project. +_Avoid_: Project ownership, sharing flag + +**Session**: +An agent or shell conversation within a Project, attributed to the User who created it and accessible according to Project Membership. +_Avoid_: Login session, authentication session diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000000..5da16850ba --- /dev/null +++ b/TODO.md @@ -0,0 +1,9 @@ +# TODO + +1. 实现 Claude Code CLI 的 /goal 能力 +2. 集成 pi agent +3. 实现切换模型,使用 setting 配置的模型或者类似 cc-switch 的功能 +4. 在同一个会话中切换任意 agent(Claude Code、Codex、pi agent 等),即一个 session 中可随时使用任一模型 +5. 集成飞书登录能力 +6. 扩展成多用户平台,增加权限概念 +7. session 中每一个 message 要展示正确的用户信息 diff --git a/docs/paseo-agent-switching-analysis.md b/docs/paseo-agent-switching-analysis.md new file mode 100644 index 0000000000..2449172df8 --- /dev/null +++ b/docs/paseo-agent-switching-analysis.md @@ -0,0 +1,565 @@ +# Paseo 同一工作上下文切换 Agent 的源码分析与实现设计 + +## 结论先行 + +Paseo 确实实现了用户所感知的“在同一个 session 中切换任意 agent/provider”,而且实现不是 UI 假象。它采用的是一组刻意分层的身份,而不是把同一个 provider 原生会话原地改成另一个 provider: + +```text +Project +└── Workspace # 稳定的任务/工作上下文,持有 cwd + ├── Agent session A # provider=claude + │ ├── Paseo agentId=A + │ ├── 独立 timeline + │ └── Claude native session ID + ├── Agent session B # provider=codex + │ ├── Paseo agentId=B + │ ├── 独立 timeline + │ └── Codex native thread ID + └── Workspace tabs/layout # 在 A、B、draft、terminal 等目标之间切换 +``` + +其关键机制是: + +1. `workspaceId` 和 `cwd` 保持不变,因此新旧 agent 看到同一份文件、Git 状态和运行环境。 +2. 每个 agent session 固定绑定一个 provider,并拥有独立的 Paseo `agentId`、timeline 和 provider-native session/thread ID。 +3. 普通的新 agent 只共享 workspace 和文件状态;需要继承对话时,用户从一个已完成的 assistant turn 执行 fork。 +4. fork 服务端按明确的 timeline cursor 截断旧 timeline,投影为 provider-neutral 的 `chat_history` 附件。 +5. 客户端打开一个 draft。draft 默认继承源 agent 的 provider/model,但在发送前可以改选任意可用 provider/model。 +6. 发送 draft 后,Paseo 在同一个 workspace 下创建新的 agent session 和新的 provider-native session,把 `chat_history` 放在新 prompt 之前。 +7. 源 agent 不被覆盖,原 timeline、运行状态和原生恢复句柄仍然属于源 agent。 + +因此,准确的工程表述是: + +> Paseo 在产品层保持同一个 workspace/task session 连续,在 provider 层创建或选择不同的 agent session。跨 provider 连续性来自共享 cwd 加显式的 turn-level context handoff,而不是让 Codex resume Claude 的 session ID。 + +这一区分不是否定 Paseo 的能力,恰恰是它能够可靠支持任意 provider 的原因。 + +## 研究基线 + +- 源仓库:`https://github.com/getpaseo/paseo.git` +- 默认分支:`main` +- 固定 commit:[`87ef631ac48fead7104b310c49c0d01e69eed3e0`](https://github.com/getpaseo/paseo/commit/87ef631ac48fead7104b310c49c0d01e69eed3e0) +- 本地只读 clone:`/tmp/paseo-analysis.IWa2kv/paseo` +- 调查日期:2026-07-31 + +下文所有 Paseo GitHub 链接都固定到该 commit,避免默认分支后续变化影响结论。`本地` 引用给出本次 clone 的文件和行号,便于复核。 + +## 必须区分的三种行为 + +Paseo 源码中有三种看起来都像“切换”的行为。只有分清它们,设计才不会把 provider、model、agent 和 workspace 混成一个概念。 + +| 行为 | 稳定身份 | 是否创建新 `agentId` | 是否创建新 native session | 是否自动带旧对话 | +| --- | --- | ---: | ---: | ---: | +| 切换 workspace 内已有 agent tab | `workspaceId` | 否,只改变焦点 | 否 | 各 tab 展示各自 timeline | +| 同 provider 切 model/热重载 | `agentId`、provider handle | 否 | 通常 resume 原 handle | 保留或从同 provider 重载 | +| 跨 provider fork/handoff | `workspaceId` | 是 | 是 | 是,以 `chat_history` 注入 | +| `/clear` 后换 provider | 当前 workspace/tab 位置 | 是 | 是 | 否,是 fresh draft | + +### 1. Workspace 内切换已有 agent tab + +Paseo 的官方文档直接说明它“围绕 workspace 而不是 chats 组织”;一个 workspace 可以同时包含多个 agent session,每个 session 是一个 tab,workspace 才是稳定容器:[`public-docs/workspaces.md` L9-L36](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/public-docs/workspaces.md#L9-L36)(本地:`public-docs/workspaces.md:9`)。创建 workspace 和向已有 workspace 添加 agent 也是两个独立动作:[`public-docs/workspaces.md` L47-L60](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/public-docs/workspaces.md#L47-L60)(本地:`public-docs/workspaces.md:47`)。 + +客户端 tab target 是 discriminated union。draft 引用 `draftId`,已创建的 agent tab 只引用 `agentId`;布局持久化 key 是 `${serverId}:${workspaceId}`:[`workspace-tabs/model.ts` L19-L46](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/workspace-tabs/model.ts#L19-L46)(本地:`packages/app/src/workspace-tabs/model.ts:19`)。布局和焦点按 workspace 保存到 AsyncStorage:[`workspace-layout-store.ts` L69-L117](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/stores/workspace-layout-store.ts#L69-L117)、[`L943-L959`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/stores/workspace-layout-store.ts#L943-L959)(本地:`packages/app/src/stores/workspace-layout-store.ts:69`、`:943`)。 + +这个路径只改变 UI 焦点,不迁移消息,也不改变任何 provider-native session。 + +### 2. 同 provider 切 model 或热重载 + +已创建 agent 的控件只从 `agent.provider` 对应的 snapshot 构造模型列表,并调用 `setAgentModel(agentId, modelId)`:[`agent-controls/index.tsx` L1441-L1517](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/composer/agent-controls/index.tsx#L1441-L1517)(本地:`packages/app/src/composer/agent-controls/index.tsx:1441`)。服务端的 `setAgentModel` 修改当前 provider session 的 model 和 agent config,并不修改 provider:[`agent-manager.ts` L1557-L1573](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L1557-L1573)(本地:`packages/server/src/server/agent/agent-manager.ts:1557`)。 + +热重载也明确锁定原 provider。`reloadAgentSession` 先取消本 agent 的活跃 run,然后取 `handle.provider ?? existing.provider`,resume 同一 provider handle 或为同 provider 新建 session;传入 override 不能把它变成另一个 provider:[`agent-manager.ts` L1196-L1243](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L1196-L1243)(本地:`packages/server/src/server/agent/agent-manager.ts:1196`)。 + +### 3. 跨 provider fork/handoff + +只有 draft controls 接收跨 provider 的 `onSelectProviderAndModel`:[`agent-controls/index.tsx` L1651-L1753](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/composer/agent-controls/index.tsx#L1651-L1753)(本地:`packages/app/src/composer/agent-controls/index.tsx:1651`)。用户改选 provider/model 时,form reducer 同时更新两者及 provider-specific 偏好:[`use-agent-form-state.ts` L426-L460](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/hooks/use-agent-form-state.ts#L426-L460)(本地:`packages/app/src/hooks/use-agent-form-state.ts:426`)。 + +这意味着跨 provider 不是 active-agent mutation,而是:源 agent -> fork context -> 可换 provider 的 draft -> 新 agent。 + +### 4. `/clear` 是 fresh replacement,不是 context handoff + +`/clear` 的定义就是“Archive this agent and start a fresh draft”:[`client-slash-commands/index.ts` L17-L35](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/client-slash-commands/index.ts#L17-L35)(本地:`packages/app/src/client-slash-commands/index.ts:17`)。处理器先把当前 tab retarget 为继承配置的 draft,再归档旧 agent:[`agent-panel.tsx` L1481-L1516](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/panels/agent-panel.tsx#L1481-L1516)(本地:`packages/app/src/panels/agent-panel.tsx:1481`)。 + +这个 draft 可以改 provider,但没有 fork 生成的 `chat_history`。因此普通 New Agent 或 `/clear` 共享 cwd/files,不自动共享旧对话。 + +## 身份与所有权模型 + +### Workspace 是稳定用户容器 + +Paseo glossary 对概念边界的定义很明确: + +- Workspace 是 daemon 上一个具体 `cwd`,拥有 agents、tabs、terminals 等 workspace-owned state。 +- Agent session 是“one provider, one model, one cwd, one timeline”。 +- Provider 是 Claude Code、Codex、OpenCode 等后端;Model 是某个 provider 提供的具体 LLM。 +- Tab 只是 workspace 内一个 session 的 UI surface。 + +证据:[`docs/glossary.md` L6-L29](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/docs/glossary.md#L6-L29)(本地:`docs/glossary.md:6`),尤其是 [`L22-L29`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/docs/glossary.md#L22-L29)。 + +持久化 workspace record 独立保存 `workspaceId/projectId/cwd/kind/title/branch/...`,不保存 active provider:[`workspace-registry.ts` L37-L85](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/workspace-registry.ts#L37-L85)(本地:`packages/server/src/server/workspace-registry.ts:37`)。`workspaceId` 是与路径独立生成的 opaque ID:[`docs/data-model.md` L479-L503](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/docs/data-model.md#L479-L503)(本地:`docs/data-model.md:479`)。 + +### Agent record 是 provider-bound 子实体 + +每个 agent record 分别保存: + +- Paseo `id` +- `provider` +- `cwd` +- owning `workspaceId` +- provider-neutral config(含 model/mode/thinking/features) +- runtime info +- provider persistence handle +- status、title、labels、archive metadata + +实现 schema:[`agent-storage.ts` L26-L82](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-storage.ts#L26-L82)(本地:`packages/server/src/server/agent/agent-storage.ts:26`)。公开 snapshot 同时暴露 `id/provider/cwd/workspaceId/model/status/persistence/runtimeInfo`:[`messages.ts` L670-L718](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/protocol/src/messages.ts#L670-L718)(本地:`packages/protocol/src/messages.ts:670`)。 + +Agent 到 Workspace 的归属是明确外键。运行时目录按 `agent.workspaceId` 分组,而不是根据 cwd 猜测:[`workspace-directory.ts` L614-L629](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/workspace-directory.ts#L614-L629)(本地:`packages/server/src/server/workspace-directory.ts:614`)。数据模型文档也把 `workspaceId` 称为 ownership 的 single source:[`docs/data-model.md` L74-L103](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/docs/data-model.md#L74-L103)(本地:`docs/data-model.md:74`)。 + +因此,同 workspace 新增 Codex agent 不修改 Claude agent record,也不修改 workspace provider;只是新增另一个 `workspaceId` 相同、`provider` 不同的 agent record。 + +## 持久化结构 + +Paseo 当前使用 `$PASEO_HOME` 下的 file-backed JSON,而非关系数据库:[`docs/data-model.md` L33-L70](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/docs/data-model.md#L33-L70)(本地:`docs/data-model.md:33`)。主要持久化分离为: + +```text +$PASEO_HOME/ +├── agents/{sanitized-cwd}/{agentId}.json +└── projects/ + ├── projects.json + └── workspaces.json +``` + +每个 agent JSON 的实际 path 由 `cwd` 派生目录和 `${agentId}.json` 组成:[`agent-storage.ts` L346-L349](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-storage.ts#L346-L349)(本地:`packages/server/src/server/agent/agent-storage.ts:346`)。bootstrap 分别创建 `AgentStorage`、project registry、workspace registry 和 `AgentManager`:[`bootstrap.ts` L773-L823](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/bootstrap.ts#L773-L823)(本地:`packages/server/src/server/bootstrap.ts:773`)。 + +这种拆分非常重要:workspace 生命周期、agent 生命周期和 provider 原生恢复句柄不会彼此覆盖。 + +## 跨 provider fork 的完整调用链 + +### 步骤 1:从已完成 turn 取得精确边界 + +fork 菜单提供 `Fork in a new tab` 和 `Fork in a new workspace`:[`assistant-fork-menu.tsx` L15-L18](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/components/assistant-fork-menu.tsx#L15-L18)、[`L97-L117`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/components/assistant-fork-menu.tsx#L97-L117)(本地:`packages/app/src/components/assistant-fork-menu.tsx:15`、`:97`)。 + +它挂在 `CompletedTurnFooter` 上,只对能解析出边界的完成 turn 显示:[`turn-footer.tsx` L134-L179](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/agent-stream/turn-footer.tsx#L134-L179)(本地:`packages/app/src/agent-stream/turn-footer.tsx:134`)。边界优先使用 Paseo timeline `{epoch, seq}`,旧 daemon 才回退到 provider message ID:[`turn-boundary.ts` L3-L22](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/agent-stream/turn-boundary.ts#L3-L22)(本地:`packages/app/src/agent-stream/turn-boundary.ts:3`)。 + +这使 fork 的含义是“截至这个 assistant turn 的状态”,而不是“请求发出时当前 timeline 的尾部”。 + +### 步骤 2:客户端请求服务端构建 context + +客户端 RPC 为: + +```ts +agent.fork_context.request { + agentId, + boundaryCursor?, + boundaryMessageId?, + requestId +} +``` + +协议 schema:[`messages.ts` L1404-L1410](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/protocol/src/messages.ts#L1404-L1410);client 调用:[`daemon-client.ts` L2840-L2873](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/client/src/daemon-client.ts#L2840-L2873)(本地:`packages/protocol/src/messages.ts:1404`、`packages/client/src/daemon-client.ts:2840`)。response 返回 `attachment/itemCount/boundaryMessageId/boundaryCursor`:[`messages.ts` L3662-L3673](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/protocol/src/messages.ts#L3662-L3673)(本地:`packages/protocol/src/messages.ts:3662`)。 + +### 步骤 3:服务端从 Paseo canonical timeline 截断 + +服务端先确保源 agent 已加载,然后以 `limit: 0` 读取全部当前 timeline rows,再把 cursor/message boundary 交给 curator:[`session.ts` L6258-L6293](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/session.ts#L6258-L6293)(本地:`packages/server/src/server/session.ts:6258`)。timeline API 的 `limit: 0` 明确定义为 selected window 中全部 rows:[`agent-timeline-store-types.ts` L16-L24](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-timeline-store-types.ts#L16-L24)(本地:`packages/server/src/server/agent/agent-timeline-store-types.ts:16`)。 + +截断发生在 raw rows 上,之后才做 tool update projection/collapse:[`activity-curator.ts` L224-L270](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/activity-curator.ts#L224-L270)(本地:`packages/server/src/server/agent/activity-curator.ts:224`)。所以同一个 tool call 在 fork 点之后出现的 completed update 不会污染 fork 点之前的上下文。对应测试:[`activity-curator.test.ts` L357-L397](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/activity-curator.test.ts#L357-L397)。 + +cursor 还带 epoch。若 UI 保存的是旧 timeline epoch,服务端拒绝而不是悄悄选择错误内容:[`activity-curator.ts` L244-L260](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/activity-curator.ts#L244-L260),测试见 [`activity-curator.test.ts` L422-L440](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/activity-curator.test.ts#L422-L440)。 + +### 步骤 4:转换成 provider-neutral `chat_history` + +curator 的确定性规则是: + +- `maxItems: 0`,不套用普通 recent activity 数量上限。 +- 只保留 `user_message`、`assistant_message`、`tool_call`。 +- tool call 变成可读摘要,不传外部工具的原始 input。 +- reasoning、todo、error 等不在 include list 中。 +- 包装为 `...`,附源 agent title 和 cwd。 + +实现:[`activity-curator.ts` L277-L336](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/activity-curator.ts#L277-L336)(本地:`packages/server/src/server/agent/activity-curator.ts:277`)。测试明确验证 reasoning 和外部工具 raw input 被排除、边界之后内容不进入附件:[`activity-curator.test.ts` L270-L329](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/activity-curator.test.ts#L270-L329);也验证 25 条旧消息没有被 generic recent limit 截掉:[`L331-L355`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/activity-curator.test.ts#L331-L355)。 + +这里的 `summary` 是 XML tag/展示名称,不代表另起一次 LLM summarization。源码是确定性的 timeline projection 和字符串渲染,没有调用模型做摘要。 + +### 步骤 5:在同一 workspace 打开可改 provider 的 draft + +客户端把 response 保存成 draft-scoped `chat_history` attachment,并记录源 `serverId/agentId/boundary/itemCount`:[`agent-stream/view.tsx` L270-L319](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/agent-stream/view.tsx#L270-L319)(本地:`packages/app/src/agent-stream/view.tsx:270`)。 + +fork handler 调用 `buildAgentForkContext` 后,将 attachment 写入 workspace attachment store;`target=tab` 时使用原 `workspaceId` 打开新 draft tab:[`agent-stream/view.tsx` L477-L516](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/agent-stream/view.tsx#L477-L516)(本地:`packages/app/src/agent-stream/view.tsx:477`)。`target=workspace` 则把同一个 draft context 带到 New Workspace flow:[`L519-L537`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/agent-stream/view.tsx#L519-L537)。 + +draft setup 默认复制源 agent 的 provider、model、mode、thinking 和 feature values,但它只是初值,不是锁定:[`agent-stream/view.tsx` L294-L311](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/agent-stream/view.tsx#L294-L311)。前述 DraftAgentControls 可以在发送前选择其他 provider/model。 + +### 步骤 6:创建新 Paseo agent 和新 native session + +draft submit 组装新的 `AgentSessionConfig`,携带同一个 `workspaceId`、`initialPrompt` 和 attachments 调用 `createAgent`:[`composer/draft/workspace-tab.tsx` L136-L209](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/composer/draft/workspace-tab.tsx#L136-L209)(本地:`packages/app/src/composer/draft/workspace-tab.tsx:136`)。wire request 也把 `config`、`workspaceId`、`initialPrompt` 和 `attachments` 分开建模:[`messages.ts` L1254-L1273](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/protocol/src/messages.ts#L1254-L1273)(本地:`packages/protocol/src/messages.ts:1254`)。 + +服务端在创建前调用 `buildAgentPrompt`;`chat_history` attachments 被固定放在新用户文本之前,图片和其他 attachments 在后:[`prompt-attachments.ts` L7-L37](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/prompt-attachments.ts#L7-L37)(本地:`packages/server/src/server/agent/prompt-attachments.ts:7`)。对应顺序测试:[`prompt-attachments.test.ts` L9-L38](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/prompt-attachments.test.ts#L9-L38)。 + +create command 解析目标 provider config、workspace ownership 和首轮 prompt:[`create-agent/create.ts` L254-L299](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/create-agent/create.ts#L254-L299)(本地:`packages/server/src/server/agent/create-agent/create.ts:254`)。`AgentManager` 生成新 `agentId`,按 `storedConfig.provider` 选择 provider client,调用其 `createSession`,并把新 session 注册到相同 `workspaceId`:[`agent-manager.ts` L1001-L1040](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L1001-L1040)(本地:`packages/server/src/server/agent/agent-manager.ts:1001`)。 + +注册后的 managed agent 同时保留 Paseo ID、provider、cwd、workspaceId、provider session 和 persistence handle:[`agent-manager.ts` L2799-L2856](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L2799-L2856)(本地:`packages/server/src/server/agent/agent-manager.ts:2799`)。 + +### 步骤 7:draft tab 原位变成新 agent tab + +create 成功后,客户端把同一个 tab 从 `{kind: "draft", draftId}` retarget 为 `{kind: "agent", agentId: newId}`:[`agent-panel.tsx` L359-L397](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/panels/agent-panel.tsx#L359-L397)(本地:`packages/app/src/panels/agent-panel.tsx:359`)。 + +这一步制造了视觉上的连续性,但没有伪造底层身份连续性:新 tab target 指向新 agent,旧 tab/agent 仍可返回。 + +## Provider-native session/thread ID 如何处理 + +Paseo 的通用持久化句柄为: + +```ts +interface AgentPersistenceHandle { + provider: AgentProvider; + sessionId: string; + nativeHandle?: string; + metadata?: AgentMetadata; +} +``` + +定义:[`agent-sdk-types.ts` L183-L189](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-sdk-types.ts#L183-L189)(本地:`packages/server/src/server/agent/agent-sdk-types.ts:183`)。通用 `AgentSession` 契约要求各 adapter 实现 `describePersistence/interrupt/close` 等行为:[`agent-sdk-types.ts` L619-L647](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-sdk-types.ts#L619-L647)。 + +具体 adapter 的含义如下: + +| Provider | `sessionId/nativeHandle` 的实际含义 | create/resume 证据 | persistence 证据 | +| --- | --- | --- | --- | +| Claude | Claude Code session ID | [`claude/agent.ts` L1458-L1501](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/claude/agent.ts#L1458-L1501)、[`L3047-L3052`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/claude/agent.ts#L3047-L3052) | [`L2433-L2446`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/claude/agent.ts#L2433-L2446) | +| Codex | Codex app-server thread ID | [`codex-app-server-agent.ts` L3175-L3209](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/codex-app-server-agent.ts#L3175-L3209)、[`L6317-L6377`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/codex-app-server-agent.ts#L6317-L6377) | [`L4207-L4231`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/codex-app-server-agent.ts#L4207-L4231) | +| OpenCode | OpenCode session ID | [`opencode-agent.ts` L1286-L1387](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/opencode-agent.ts#L1286-L1387) | [`L4137-L4147`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/opencode-agent.ts#L4137-L4147) | +| ACP providers | ACP session ID | [`acp-agent.ts` L760-L843](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/acp-agent.ts#L760-L843) | [`L2006-L2018`](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/acp-agent.ts#L2006-L2018) | + +ACP resume 甚至显式拒绝 `handle.provider !== this.provider`:[`acp-agent.ts` L796-L815](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/acp-agent.ts#L796-L815)。通用 resume path 也强制 `provider: handle.provider` 并只取得对应 client:[`agent-manager.ts` L1051-L1120](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L1051-L1120)(本地:`packages/server/src/server/agent/agent-manager.ts:1051`)。 + +所以 native ID 必须至少以 `(provider, nativeSessionId)` 解释,并且只属于一个 app-level agent session。跨 provider handoff 必然创建新的 native session。 + +## Timeline 与历史连续性 + +### 每个 agent 的 timeline 独立 + +Paseo timeline store 的全部 API 都以 `agentId` 为 key,每行有 `seq/timestamp/item`,cursor 为 `{epoch, seq}`:[`agent-timeline-store-types.ts` L3-L60](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-timeline-store-types.ts#L3-L60)(本地:`packages/server/src/server/agent/agent-timeline-store-types.ts:3`)。当前默认 manager 使用 `Map` 的 in-memory timeline:[`agent-timeline-store.ts` L138-L185](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-timeline-store.ts#L138-L185)(本地:`packages/server/src/server/agent/agent-timeline-store.ts:138`)。 + +当前 bootstrap 创建 `AgentManager` 时没有注入可选的 `durableTimelineStore`:[`bootstrap.ts` L813-L823](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/bootstrap.ts#L813-L823)。因此 daemon 重启后,agent record 和 provider handle 持久化,Paseo timeline 则通过 provider history 重建:`ensureAgentLoaded` 先按 handle resume,再调用 `hydrateTimelineFromProvider`:[`agent-loading.ts` L62-L135](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-loading.ts#L62-L135)(本地:`packages/server/src/server/agent/agent-loading.ts:62`);manager 将 provider `streamHistory()` 事件重新记录到 agent timeline:[`agent-manager.ts` L3134-L3267](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L3134-L3267)(本地:`packages/server/src/server/agent/agent-manager.ts:3134`)。 + +### Fork 不是把两条 timeline 物理拼接 + +源 agent timeline 和目标 agent timeline 始终独立。目标 timeline 的第一轮 prompt 含源 history attachment,于是目标 provider 在自己的原生 conversation 中“知道之前发生了什么”;UI 仍可回到源 agent 的原 timeline。 + +因此连续性有两部分: + +1. 物理工作状态:相同 workspace/cwd 下的文件、Git diff、进程和 terminal。 +2. 对话语义状态:显式、可审计、带边界的 `chat_history` context。 + +## 并发、取消、权限和消息队列 + +### 不同 agent 可以并行,同一 agent 拒绝重复 foreground run + +服务端 run map 按 `agentId` 保存:[`agent-run-state.ts` L40-L75](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-run-state.ts#L40-L75)(本地:`packages/server/src/server/agent/agent-run-state.ts:40`)。所以同 workspace 的 Claude agent 和 Codex agent 可以并行运行。对同一 `agentId`,如果已有 active turn/tracked run,`streamAgent` 抛出 `already has an active run`:[`agent-manager.ts` L1943-L1976](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L1943-L1976)(本地:`packages/server/src/server/agent/agent-manager.ts:1943`)。 + +replace 语义会先 cancel 这个 agent 的 run,再启动下一轮:[`agent-manager.ts` L2088-L2117](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L2088-L2117)。它不会取消同 workspace 的其他 agent。 + +### Fork 不隐式取消源 agent + +fork context handler 是只读的 ensure-loaded + fetch-timeline + build-attachment 路径,没有调用 cancel。由于边界先于 projection,即便源 agent 已经继续产生后续 timeline rows,目标 context 仍只到选中的 turn。由此可得:fork 是 branch 语义,不是 replace 语义;源 agent 可以保持运行或等待其自己的 permission。 + +如果产品要提供“切换并停止旧 agent”,应把它设计成显式的 replace policy,而不能把 cancel 暗藏在 context fork 中。 + +### Permission 和 cancel 始终路由到确切 agent + +permission response 接收 `agentId + requestId`,再调用该 agent session 的 adapter:[`agent-manager.ts` L2226-L2258](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L2226-L2258)(本地:`packages/server/src/server/agent/agent-manager.ts:2226`)。cancel 也只 interrupt 目标 `agentId` 的 session,并清理该 agent 的 pending permissions:[`agent-manager.ts` L2261-L2311](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L2261-L2311)(本地:`packages/server/src/server/agent/agent-manager.ts:2261`)。 + +UI 当前焦点、workspace active tab 或“最新创建 agent”都不能替代这个执行归属 key。 + +### 队列按 agent 隔离,切 tab/fork 不转移旧队列 + +客户端 queue 是 `Map`:[`session-store.ts` L388-L392](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/stores/session-store.ts#L388-L392)(本地:`packages/app/src/stores/session-store.ts:388`)。enqueue 明确写入 `input.agentId` 的数组:[`composer/actions.ts` L246-L261](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/composer/actions.ts#L246-L261)(本地:`packages/app/src/composer/actions.ts:246`)。 + +agent 从 running 变为 stopped 时,只 drain 对应 `agentId`:[`directory-sync/agent-replica.ts` L61-L97](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/runtime/directory-sync/agent-replica.ts#L61-L97)(本地:`packages/app/src/runtime/directory-sync/agent-replica.ts:61`)。drain 用 `${serverId}:${agentId}` 防止同 agent 并发 drain,并从相同 agent queue 取首条发送:[`host-runtime.ts` L2051-L2097](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/runtime/host-runtime.ts#L2051-L2097)(本地:`packages/app/src/runtime/host-runtime.ts:2051`)。发送失败会把原消息恢复到该 queue 的最前面:[`composer/actions.ts` L307-L335](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/composer/actions.ts#L307-L335)。 + +所以 fork 到新 agent 后,旧 agent 已排队的消息仍属于旧 agent;Paseo 不把它们偷偷发送给新 provider。 + +## Provider、Model、Agent、Run 的关系 + +```text +Provider 1 ── N Model +Provider 1 ── N AgentSession +Workspace 1 ── N AgentSession +AgentSession 1 ── 1 current provider-native handle +AgentSession 1 ── N Turn/Run(通常同一时刻最多一个 foreground run) +``` + +`AgentModelDefinition` 自带所属 `provider`:[`agent-sdk-types.ts` L75-L85](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-sdk-types.ts#L75-L85)(本地:`packages/server/src/server/agent/agent-sdk-types.ts:75`)。模型列表也是先按 provider snapshot 查询,再解析该 provider 的 default model:[`provider-snapshot-manager.ts` L318-L340](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/provider-snapshot-manager.ts#L318-L340)(本地:`packages/server/src/server/agent/provider-snapshot-manager.ts:318`)。 + +设计不变量应是: + +- model 不是 agent,也不是 provider;model ID 必须在目标 provider catalog 中解析。 +- 同 provider 且 adapter 支持时,可以在同一个 agent session 内切 model。 +- provider 一旦产生 native handle,就不应原地修改;换 provider 创建新 agent session。 +- run/turn 是 agent session 内的一次执行,不是 session 本身。 + +## 对 `cli-web-ui` 当前实现的映射 + +以下是本仓库源码事实,不是 Paseo 源码事实。 + +### 已经具备的基础 + +当前 `sessions` 表已经把应用 ID 和 provider-native ID 分开:`session_id` 是前端稳定 ID,`provider_session_id` 在 provider 首次报告原生 ID 后填入;同一行还保存一个 `provider` 和一个 `model`。见本地 [`server/modules/database/schema.ts:99`](../server/modules/database/schema.ts#L99) 和 [`server/modules/database/repositories/sessions.db.ts:148`](../server/modules/database/repositories/sessions.db.ts#L148)。 + +创建新会话时,服务端先生成 app `sessionId`,provider-native ID 为空:[`server/modules/providers/services/sessions.service.ts:150`](../server/modules/providers/services/sessions.service.ts#L150)。provider runtime 后续报告原生 ID 时,run registry 把映射写回该 app session:[`server/modules/websocket/services/chat-run-registry.service.ts:163`](../server/modules/websocket/services/chat-run-registry.service.ts#L163)。 + +运行态也已经按 app `sessionId` 隔离:run registry 是 `Map`,同一 app session 拒绝并发 run,不同 app session 可以并行:[`server/modules/websocket/services/chat-run-registry.service.ts:57`](../server/modules/websocket/services/chat-run-registry.service.ts#L57)、[`L207`](../server/modules/websocket/services/chat-run-registry.service.ts#L207)。 + +这些都可以直接保留。现有 `sessions` 实际上已经很接近 Paseo 的 provider-bound `AgentSession`。 + +### 当前不能原地改 provider 的原因 + +目前一条 `sessions` row 只有一个 `provider` 和一个 `provider_session_id`。`chat.send` 不相信客户端提供 provider,而是用 `sessionId` 读取数据库行,随后据此选择 runtime、resume ID、cwd 和 project path:[`server/modules/websocket/services/chat-websocket.service.ts:141`](../server/modules/websocket/services/chat-websocket.service.ts#L141)。 + +历史读取同样先由 session row 解析 provider,再把该行的 native ID 交给对应 adapter:[`server/modules/providers/services/sessions.service.ts:178`](../server/modules/providers/services/sessions.service.ts#L178)。因此直接执行: + +```sql +UPDATE sessions SET provider = 'codex' WHERE session_id = ''; +``` + +会让后续 history、resume、abort、permission 和 token usage 用 Codex adapter 解释 Claude native ID。这不是切换,而是破坏身份映射。 + +前端也显式把打开的 session 锁回其 `__provider`:[`src/components/chat/hooks/useChatProviderState.ts:454`](../src/components/chat/hooks/useChatProviderState.ts#L454)。只有尚未创建 session 的 empty draft selector 才显示所有 provider/model:[`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx:175`](../src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx#L175)、[`L186`](../src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx#L186)。 + +### 缺失的父级身份 + +当前 route/sidebar identity 与 provider-bound app `sessionId` 是同一个概念。要获得 Paseo 等价能力,需要在它上面增加稳定的 `Conversation` 或 `WorkspaceConversation`: + +```text +Conversation / WorkspaceConversation +└── 1..N existing sessions rows(每行就是一个 provider-bound AgentSession) +``` + +换 provider 时新增 session row,而不是更新旧行 provider。旧 `session_id -> provider_session_id` gateway、runtime adapter、history loader 和 abort 路由都可以继续使用。 + +## 面向 `cli-web-ui` 的推荐设计 + +本节是基于 Paseo 机制的设计建议,不声称是 Paseo 当前源码结构。 + +### 推荐领域模型 + +```text +Conversation + id + projectPath / cwd + title + activeAgentSessionId? # 只表示 UI 默认焦点,不表示执行归属 + createdAt / updatedAt / archivedAt + +AgentSession # 可先继续使用现有 sessions 表名 + sessionId # 现有稳定 app ID + conversationId # 新增 FK + previousSessionId? # lineage/handoff edge + provider # native handle 创建后不可变 + model + providerSessionId? # 现有 provider-native ID + status + createdAt / updatedAt / archivedAt + +Handoff + handoffId # client idempotency key + conversationId + sourceSessionId + targetSessionId? + sourceBoundary + contextText / contextHash + targetProvider / targetModel + status: preparing | ready | submitted | failed + +TimelineEvent(若要单一视觉时间线) + conversationId + agentSessionId # 永远保留真实 producer + runId / turnId? + sequence + kind / content / metadata +``` + +如果产品希望像 Paseo 一样用多个 tab,可以不立即建设统一 `TimelineEvent` 表,每个 session 继续读取自己的 provider history。如果产品要求 URL 和聊天窗都保持“同一个 session”,则 `conversationId` 应成为 URL/sidebar identity,UI 把各 agent session 渲染为带明确 `Claude -> Codex` boundary 的连续 segments。 + +### 推荐切换事务 + +1. 客户端提交 `conversationId/sourceSessionId/sourceBoundary/targetProvider/targetModel/handoffId`。 +2. 服务端校验 source session 属于该 conversation,并校验目标 model 属于目标 provider。 +3. 按明确 boundary 从 source 的 normalized history 构建 server-owned handoff context;客户端不能伪造任意 history attachment。 +4. 在同一 conversation/projectPath 下创建新的 app session row,provider-native ID 初始为空。 +5. 将 handoff context 放在目标 provider 第一条用户 prompt 之前。 +6. 目标 runtime 创建自己的 native session/thread 后,只把 native ID 写入目标 session row。 +7. 创建成功后再更新 conversation 的默认 active session;失败时保留源 session 和源 active pointer。 +8. 源 session、源 native handle、源 queue 和源 pending permission 保持原归属。 +9. 对相同 `handoffId` 返回同一个 target session,避免网络重试创建重复 agent。 + +建议把两种操作明确区分: + +- `fork`: 不取消 source run,允许源/目标并行。 +- `replace`: 先按 `sourceSessionId` settle/abort,再创建目标 session。 + +### 推荐 handoff 状态机 + +SQLite 写入和外部 provider 创建不可能处在同一个数据库原子事务中,因此不能只用一个 `active` 布尔值描述切换。建议以 `handoffId` 为状态机主键: + +```text +requested + │ validate source ownership + target provider/model + ▼ +preparing_context + │ snapshot source boundary + build normalized context + ▼ +creating_target + │ create target app session row (native id is NULL) + ▼ +target_allocated + │ submit first prompt + context to target runtime + ▼ +starting_provider + │ capture and persist target providerSessionId + ▼ +ready + │ update conversation.activeAgentSessionId / focus target + ▼ +completed +``` + +失败分支: + +- `requested/preparing_context -> failed_validation`: 不创建 target,不改变 source。 +- `creating_target -> failed_allocation`: 不改变 source,可用相同 `handoffId` 重试。 +- `target_allocated/starting_provider -> failed_start`: target row 标记 failed 或 archived,source pointer 不变;不能把 source native ID 填入 target。 +- 客户端超时后重试:服务端按 `handoffId` 返回已有状态/`targetSessionId`,不能再分配一条 target session。 +- `replace` mode 的 source cancel 若被 provider 拒绝,应在创建 target 前失败;`fork` mode 不经过 source cancel。 + +状态转换、target session 分配和 conversation pointer 更新应分别使用短数据库事务;等待 provider 的网络/进程调用时不能持有 SQLite transaction。`activeAgentSessionId` 只是默认 UI 焦点,即使状态到 `ready`,source run、permission 和 queue 仍通过 source `sessionId` 独立存活。 + +### API 草案 + +```http +POST /api/conversations/:conversationId/handoffs +Idempotency-Key: +Content-Type: application/json + +{ + "sourceSessionId": "app-session-claude", + "boundary": { "messageId": "..." }, + "target": { + "provider": "codex", + "model": "gpt-5.4" + }, + "mode": "fork", + "initialPrompt": "继续完成测试并修复失败" +} +``` + +```json +{ + "conversationId": "conversation-1", + "sourceSessionId": "app-session-claude", + "targetSessionId": "app-session-codex", + "handoffId": "client-generated-id", + "status": "ready" +} +``` + +WebSocket 运行协议仍应使用 `targetSessionId`: + +```json +{ + "type": "chat.send", + "sessionId": "app-session-codex", + "content": "继续完成测试并修复失败", + "options": { + "handoffId": "client-generated-id" + } +} +``` + +`chat.abort`、permission decision、queue、stream subscribe 也继续使用精确 `sessionId`,不能只传 `conversationId` 或“当前 active session”。 + +### Backend 模块落点 + +按本仓库 backend module 约束,建议: + +- 新的父级聚合及 handoff orchestration 放在 `server/modules/conversations/`,由 `index.ts` 暴露最小 public API。 +- transport route 只校验参数、调用 service、格式化 response。 +- 现有 provider/session history 与 runtime 能力继续通过 `server/modules/providers/index.ts` 使用,避免跨模块 deep import。 +- 跨模块使用的 handoff request/result 类型放入 `server/shared/types.ts`,使用 `export type`/`import type` 并补完整约束注释。 +- context projection、idempotency、目标 session 创建和失败补偿属于 service/store,不放 route。 +- tests 放在 owning module 的 `tests/` 下。 + +### 队列和权限策略 + +建议完全复制 Paseo 的 ownership 原则: + +- queue key 为 source/target `sessionId`,切换 UI 时不自动搬迁 queued messages。 +- 尚未入队的 composer draft 可以由用户明确带到 target;已经排队的消息属于其原 session。 +- permission key 至少为 `(sessionId, requestId)`。 +- abort key 为精确 source `sessionId`,不能解析 conversation 当前焦点后再取消。 +- sidebar/conversation 可聚合显示多个 session 的 running/permission badge,但点击后路由到真实 owner。 + +### Context 大小与安全 + +Paseo 当前 fork 对条目数不设上限,但目标 provider 仍有 context window。移植时建议在确定性 projection 后增加 token budget:优先保留目标、最近 turns、文件变更、测试结果和 unresolved work;超限时再做可审计的 deterministic truncation 或显式 model summary,并记录被裁剪范围。 + +handoff context 应由服务端从已授权 source history 构建,并作为一种独立的 server-owned attachment type。不要复用当前图片文件 path attachment 的 trust boundary,也不要允许浏览器提交任意本地路径或把任意文本标记成 trusted history。 + +## 实施阶段建议 + +### Phase 1:最小可用跨 provider fork + +1. 增加 conversation parent 和 `sessions.conversation_id`。 +2. 将旧 session 一对一 backfill 成 conversation,保持所有旧 URL/API 可读。 +3. 增加 server-side handoff context builder 和 idempotent handoff endpoint。 +4. provider switch 创建新 sessions row,并通过现有 `chat.send` 发给目标 runtime。 +5. UI 在当前 session 完成 turn 后提供“Fork with...”入口,目标选择器复用新会话 provider/model catalog。 +6. 旧 session history、abort、permission 和 queue 保持原逻辑。 + +### Phase 2:单一视觉 conversation + +1. route/sidebar 改用 conversation ID,保留 `/session/:id` redirect/兼容解析。 +2. conversation 页面加载全部 agent-session segments。 +3. 插入可见的 provider switch boundary,并允许返回任一旧 segment。 +4. 聚合运行态和 attention badge,但所有执行命令仍携带真实 session ID。 + +### Phase 3:持久化 canonical timeline + +1. 为 provider-normalized events 增加稳定 message ID、epoch/seq cursor。 +2. fork 精确到 turn boundary,拒绝 stale cursor。 +3. 支持 source 后续仍运行时的稳定 branch,以及 context preview/audit。 +4. 建立 token-budget、redaction 和 provider capability policy。 + +## 必测场景 + +- Claude -> Codex、Codex -> Claude、OpenCode -> Claude 均创建新的 app session 和新的 native ID。 +- target native ID 永远不会覆盖 source row,source history/resume 仍走原 provider adapter。 +- 同 provider 只切 model 时不创建新的 agent session;跨 provider 必须创建。 +- source 正在运行时 fork 已完成 turn,不取消 source,context 不包含 boundary 后事件。 +- replace mode 只取消 source session,不影响同 conversation 的其他 sessions。 +- source 有 pending permission 时,permission 仍显示并只能用 source session ID 响应。 +- source 有 queued messages 时,切换后 queue 不进入 target;target queue 独立。 +- handoff 网络重试使用相同 idempotency key,只产生一个 target session。 +- target provider 启动失败时,source active pointer、history 和 native handle 不变。 +- stale/missing boundary 返回明确错误,不静默使用 timeline 尾部。 +- history context 位于 target 首条 user prompt 前;reasoning/private raw tool input 不进入 handoff。 +- context 超过 target model budget 时执行定义好的裁剪策略并可观察。 +- daemon/browser 重启后 conversation、source/target sessions 及各自 native handle 都能恢复。 + +## 不应采用的实现 + +- 不要在同一 session row 上直接改 `provider`。 +- 不要把 Claude session ID 传给 Codex resume API,反之亦然。 +- 不要只在前端拼接旧消息,而服务端仍认为当前 session 属于旧 provider。 +- 不要用 cwd 当 workspace/conversation identity;相同 cwd 可以有多个独立工作上下文。 +- 不要用 UI 当前 tab/active session 推断 abort、permission、queue 或 stream owner。 +- 不要在切换时覆盖或删除 source native handle。 +- 不要把普通 New Agent 宣称为自动继承对话;需要 context 时必须走 fork/handoff。 +- 不要让 model selector 跨 provider 后仍调用现有 session 的 `setModel`。 + +## 最终判断 + +Paseo 的实现可以概括为两个正交平面: + +```text +共享状态平面:Workspace -> cwd/files/git/terminal/layout +执行状态平面:AgentSession -> provider/model/native handle/timeline/run/permission/queue +``` + +跨 provider 时,共享状态平面不变,执行状态平面新增一个 agent session;显式 handoff attachment 把源执行上下文桥接到目标执行上下文。这个模型既满足用户体验上的“同一个 session 里切换任意 agent”,又保留每个 provider 对自己原生 session/thread 的正确所有权和可恢复性。 diff --git a/docs/paseo-provider-switch-design.md b/docs/paseo-provider-switch-design.md new file mode 100644 index 0000000000..a96c41d61f --- /dev/null +++ b/docs/paseo-provider-switch-design.md @@ -0,0 +1,613 @@ +# Paseo 跨 Provider 会话切换机制与 CloudCLI 实现设计 + +## 结论 + +Paseo 确实实现了用户所感知的“在同一个任务/session 中切换任意 agent/provider”。其核心不是让一个 Claude 原生 session 变成 Codex thread,而是把身份分成三层: + +```text +Project +└── Workspace # 稳定的用户任务容器 + ├── Agent session A # provider=claude + │ └── Claude native session id + ├── Agent session B # provider=codex + │ └── Codex native thread id + └── Agent session C # provider=opencode / ACP + └── provider-native handle +``` + +用户留在同一个 Workspace;每次跨 provider 都创建新的 Paseo `agentId` 和新的 provider-native handle。旧 agent 及其原生恢复句柄继续保留。需要继承上下文时,Paseo 从源 agent 的规范化 timeline 生成 `chat_history`,将它作为目标 agent 首次 prompt 的上下文附件。 + +对 CloudCLI,正确的目标同样是: + +```text +Conversation (稳定 URL / 侧边栏条目) +└── 1..N AgentSession (每个 provider leg 一个不可变身份) + └── 0..1 provider-native session/thread +``` + +不能通过原地更新当前 `sessions.provider` 和 `provider_session_id` 实现切换。那样会破坏旧历史、恢复、取消、权限、usage 和 watcher 去重的归属关系。 + +## 研究基线 + +- Paseo repository: +- Paseo commit: [`87ef631ac48fead7104b310c49c0d01e69eed3e0`](https://github.com/getpaseo/paseo/commit/87ef631ac48fead7104b310c49c0d01e69eed3e0) +- CloudCLI commit: `d9d3c12770dfc83a3e7ab937f640a0ac60949992` +- Paseo PR [#1788](https://github.com/getpaseo/paseo/pull/1788): 首次实现从 assistant turn 生成 chat-history attachment 并 fork 到 draft。 +- Paseo PR [#2022](https://github.com/getpaseo/paseo/pull/2022): 把 fork chat 扩展到所有 agent provider。 +- Paseo changelog 明确记录 “Fork chats with every supported agent provider”: [`CHANGELOG.md` L202](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/CHANGELOG.md#L202)。 + +## Paseo 的真实实现 + +### 1. Workspace 是稳定 session,Agent session 是 provider-bound leg + +Paseo 的产品文档明确说明它以 workspace 而不是 chat 为组织单位,一个 workspace 可以包含多个同时存在的 session: + +- [`public-docs/workspaces.md` L9-L36](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/public-docs/workspaces.md#L9-L36) +- [`public-docs/workspaces.md` L47-L60](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/public-docs/workspaces.md#L47-L60) + +一个 agent session 则固定对应一个 provider、model、cwd 和 timeline: + +- [`docs/glossary.md` L24-L29](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/docs/glossary.md#L24-L29) + +Workspace 自身保存稳定 `workspaceId`、`projectId` 和 `cwd`: + +- [`workspace-registry.ts` L37-L85](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/workspace-registry.ts#L37-L85) + +每个 agent record 独立保存 `id`、`provider`、`workspaceId`、配置、runtime 信息和 persistence handle: + +- [`agent-storage.ts` L13-L82](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-storage.ts#L13-L82) + +Agent 到 Workspace 是显式 ownership,不在运行时通过 cwd 猜测: + +- [`agent-manager.ts` L303-L329](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L303-L329) +- [`workspace-directory.ts` L614-L629](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/workspace-directory.ts#L614-L629) + +### 2. 跨 provider 会创建新 agent,而不是改写旧 agent + +新 agent 请求把 `workspaceId` 与 `config.provider/model` 分开传递。创建流程生成新的 `agentId`,按目标 provider 选择 client,创建目标 provider 的原生 session,再把新 agent 归入原 workspace: + +- [`create-agent/create.ts` L173-L186](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/create-agent/create.ts#L173-L186) +- [`create-agent/create.ts` L226-L299](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/create-agent/create.ts#L226-L299) +- [`agent-manager.ts` L1001-L1040](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L1001-L1040) +- [`agent-manager.ts` L2799-L2848](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L2799-L2848) + +### 3. Provider-native handle 严格绑定 provider + +Paseo 的通用持久化句柄同时保存 provider discriminator 和 native session id: + +- [`agent-types.ts` L140-L160](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/protocol/src/agent-types.ts#L140-L160) + +恢复时,`AgentManager` 强制选择 `handle.provider` 对应的 client;即使传入 override,也不能借此把旧 handle 换到另一个 provider: + +- [`agent-manager.ts` L1072-L1120](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L1072-L1120) +- [`agent-manager.ts` L1196-L1242](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L1196-L1242) + +ACP adapter 对 provider mismatch 直接抛错: + +- [`acp-agent.ts` L796-L815](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/acp-agent.ts#L796-L815) + +不同 adapter 分别保存自己的原生身份: + +- Claude session id: [`claude/agent.ts` L2433-L2447](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/claude/agent.ts#L2433-L2447) +- Codex thread id: [`codex-app-server-agent.ts` L4207-L4231](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/codex-app-server-agent.ts#L4207-L4231) +- OpenCode session id: [`opencode-agent.ts` L4137-L4148](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/providers/opencode-agent.ts#L4137-L4148) + +所以 provider-native key 的完整类型至少是 `(provider, nativeSessionId)`,且它只属于一个 app agent session。 + +### 4. 上下文连续性来自 fork/handoff + +Paseo 的协议支持从明确 timeline 边界生成 fork context: + +- request/response: [`messages.ts` L1404-L1410](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/protocol/src/messages.ts#L1404-L1410)、[`messages.ts` L3662-L3673](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/protocol/src/messages.ts#L3662-L3673) +- server 读取源 agent canonical tail: [`session.ts` L6258-L6311](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/session.ts#L6258-L6311) + +Curator 的关键行为: + +1. 在 collapse/update 之前按精确 sequence/cursor 选择原始 timeline rows。 +2. 检查 timeline epoch,拒绝 stale cursor。 +3. 把 provider-specific rows 投影为统一 timeline item。 +4. 保留 user/assistant 文本和经过摘要的 tool call。 +5. 排除 reasoning、raw external tool input 和不应转移的运行时噪音。 +6. 生成 `text/plain`、`contextKind: chat_history` 的附件,并用 `` 包裹。 + +源码:[`activity-curator.ts` L224-L336](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/activity-curator.ts#L224-L336)。 + +历史附件会排在新 user text 前面: + +- [`prompt-attachments.ts` L7-L38](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/prompt-attachments.ts#L7-L38) + +UI 把 attachment 放进同 workspace 的 draft,默认继承源 provider/model,但 draft 的 provider/model 可改为任意可用项: + +- [`agent-stream/view.tsx` L270-L319](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/agent-stream/view.tsx#L270-L319) +- [`agent-stream/view.tsx` L477-L541](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/agent-stream/view.tsx#L477-L541) +- [`workspace-tab.tsx` L136-L209](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/composer/draft/workspace-tab.tsx#L136-L209) + +最终序列为: + +```text +source agent + -> canonical normalized timeline through boundary + -> curated chat_history + -> same-workspace draft + -> choose arbitrary provider/model + -> new agentId + -> new provider-native session/thread + -> chat_history + new user prompt +``` + +### 5. Model switch 与 provider switch 是两条路径 + +已运行 agent 的 model selector 只展示当前 `agent.provider` 的 model,并调用当前 agent 的 model mutation: + +- [`agent-controls/index.tsx` L1417-L1466](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/composer/agent-controls/index.tsx#L1417-L1466) +- [`agent-controls/index.tsx` L1493-L1517](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/app/src/composer/agent-controls/index.tsx#L1493-L1517) + +因此应保持以下语义: + +```text +同 provider 切 model -> 更新当前 AgentSession 的 model/config +跨 provider -> 新建 AgentSession + 新 native handle + context handoff +``` + +### 6. 并发、取消和权限不跟随 UI focus + +Paseo 每个 agent 只允许一个 active foreground run: + +- [`agent-manager.ts` L1943-L1976](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L1943-L1976) + +同一 agent 的 provider event 被串行处理: + +- [`agent-manager.ts` L2916-L2993](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L2916-L2993) + +取消会等待 provider acknowledgement/terminal settlement;取消失败时 replacement/reload/rewind 不能继续: + +- [`agent-manager.ts` L2261-L2348](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L2261-L2348) + +权限请求属于具体 agent runtime,不会搬到目标 provider: + +- [`agent-manager.ts` L2226-L2258](https://github.com/getpaseo/paseo/blob/87ef631ac48fead7104b310c49c0d01e69eed3e0/packages/server/src/server/agent/agent-manager.ts#L2226-L2258) + +## CloudCLI 当前架构评估 + +### 已经具备的基础 + +CloudCLI 当前已经有第一层 identity indirection: + +- `sessions.session_id` 是稳定 app-facing id。 +- `sessions.provider_session_id` 是 provider-native id。 +- 新会话在第一次 WebSocket send 之前就分配 app id。 +- provider runtime 内部使用 native id resume。 +- `ChatSessionWriter` 把 provider-native event 的 `sessionId` 改写回 app id。 + +关键源码: + +- 表结构:`server/modules/database/schema.ts` L99-L123。 +- app/native ID 映射:`server/modules/database/repositories/sessions.db.ts` L148-L214。 +- 建立 app session:`server/modules/providers/services/sessions.service.ts` L150-L176。 +- history 使用数据库中的 provider/native id:同文件 L178-L225。 +- WebSocket 从数据库解析 provider,不信任 client provider:`server/modules/websocket/services/chat-websocket.service.ts` L141-L231。 +- writer 隐藏 provider-native id:`server/modules/websocket/services/chat-session-writer.service.ts` L30-L145。 +- provider dispatcher:`server/modules/providers/services/provider-runtime.service.ts` L35-L106。 + +这意味着无需推翻现有 adapter。需要把当前 `AppSession 1:1 ProviderSession` 扩展为 `Conversation 1:N AgentSession`。 + +### 当前阻止跨 provider 的绑定点 + +1. `sessions` 一行只能保存一个 `provider/provider_session_id/model/jsonl_path`。 +2. `chat.send` 从这一行选择唯一 provider。 +3. `chatRunRegistry` 直接按 app `sessionId` 键控运行。 +4. `fetchHistory` 只调用这一行的一个 provider history adapter。 +5. `ChatSessionWriter` 只能把一个 native id 绑定回一个 app session。 +6. 前端 provider selector 仅在 `!selectedSession && !currentSessionId` 时展示:`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` L175-L187。 +7. 打开已有 session 后,`useChatProviderState` 强制同步到 `selectedSession.__provider`:`src/components/chat/hooks/useChatProviderState.ts` L454-L461。 +8. queued message 只保存 conversation/session id,没有保存 expected agent segment:`src/hooks/useQueuedMessageAutoSend.ts` L20-L68。 + +因此直接执行下面的更新是错误方案: + +```sql +UPDATE sessions +SET provider = 'codex', provider_session_id = NULL +WHERE session_id = :id; +``` + +它会让旧 Claude transcript 失去 owner,resume 走错 adapter,abort/permission 路由可能命中错误 runtime,watcher 后续还会重新插入或错误合并旧 native session。 + +## CloudCLI 目标数据模型 + +为了兼容当前代码,建议新增 `conversations` 根表,并继续把当前 `sessions` 表作为 provider-bound agent segment 使用。第一阶段不必物理重命名表,代码类型和服务中应明确叫 `AgentSession`。 + +```text +Project +└── Conversation # 稳定 URL: /session/:conversationId + ├── activeAgentSessionId + ├── AgentSession 1 (sessions row) # claude + native id + ├── AgentSession 2 (sessions row) # codex + native id + └── AgentSession N +``` + +建议 schema: + +```sql +CREATE TABLE conversations ( + conversation_id TEXT PRIMARY KEY, + project_path TEXT NOT NULL, + custom_name TEXT, + active_agent_session_id TEXT, + version INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + is_archived INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_path) REFERENCES projects(project_path) +); + +ALTER TABLE sessions ADD COLUMN conversation_id TEXT; +ALTER TABLE sessions ADD COLUMN ordinal INTEGER; +ALTER TABLE sessions ADD COLUMN previous_session_id TEXT; +ALTER TABLE sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; +ALTER TABLE sessions ADD COLUMN handoff_id TEXT; +ALTER TABLE sessions ADD COLUMN handoff_boundary_seq INTEGER; +ALTER TABLE sessions ADD COLUMN context_digest TEXT; + +CREATE UNIQUE INDEX idx_sessions_conversation_ordinal + ON sessions(conversation_id, ordinal); + +CREATE UNIQUE INDEX idx_sessions_provider_native + ON sessions(provider, provider_session_id) + WHERE provider_session_id IS NOT NULL; +``` + +`provider` 在 native handle 建立后不可变。`model` 可以在同 provider 且 provider 支持时更新。 + +建议增加两类持久化记录: + +```text +ConversationEvent + eventId + conversationId + agentSessionId + runId / turnId + seq # conversation 内单调递增 + providerMessageId + kind / role / payload + visibility + +Handoff + handoffId # 幂等键 + conversationId + sourceAgentSessionId + targetAgentSessionId + boundarySeq + contextText / contextJson + contextDigest + estimatedTokens + status +``` + +Canonical `ConversationEvent` 很重要。当前历史由各 provider artifact 临时读取,跨多个 segment 做稳定边界、去重和分页会很脆弱。Gateway 应把 live normalized semantic events 写入 canonical timeline;`stream_delta` 可继续只放内存,turn 完成后用 provider transcript 对账并 upsert final message/tool rows。 + +每条消息、run、permission、usage 必须保留 `agentSessionId`,即使 UI 只显示一个连续 conversation。 + +## 切换协议 + +当前 Claude/Codex/Cursor/OpenCode adapter 通常要在第一次 prompt 时才真正得到 native session/thread id。因此,不建议仅点选 provider 就创建一个空 native session。UI 选择目标 provider 时先形成本地 draft;下一次发送使用一个明确的原子意图: + +```json +{ + "type": "chat.switch-and-send", + "conversationId": "conv-123", + "expectedSourceAgentSessionId": "agent-claude-1", + "expectedConversationVersion": 7, + "idempotencyKey": "client-generated-uuid", + "target": { + "provider": "codex", + "model": "gpt-5.4" + }, + "boundary": { + "eventId": "evt-456", + "seq": 123 + }, + "cancelRunning": false, + "contextMode": "curated", + "content": "继续完成剩余测试", + "options": {} +} +``` + +普通同-provider turn 继续使用 `chat.send`,但也应加入: + +```json +{ + "conversationId": "conv-123", + "expectedAgentSessionId": "agent-codex-2", + "clientMessageId": "uuid" +} +``` + +服务端永远通过 agent-session row 解析 provider/native handle;不能直接把客户端 `target.provider` 用于 resume 已有 handle。 + +## 两阶段切换状态机 + +Provider runtime 是异步外部系统,不能把整个创建过程包在 SQLite transaction 里。应使用 reservation + activation 两阶段流程: + +```text +source active/idle + | + | reserve (DB transaction) + v +target initializing, source still active + | + | start target runtime with handoff + user prompt + v +native handle captured + | + | activate (DB transaction) + v +target active, source historical, conversation version++ +``` + +详细步骤: + +1. 读取 conversation,并校验 `expectedSourceAgentSessionId` 和 `version`。 +2. 如果 source run 仍运行且 `cancelRunning=false`,返回 `409 RUN_IN_PROGRESS`。 +3. 如果请求取消,先向 source adapter 发 abort,并等待明确 terminal settlement;未确认不能继续。 +4. 终结或拒绝 source 的 pending permissions,不把它们转移到 target。 +5. 在明确的 completed assistant boundary 读取 canonical timeline。 +6. 生成 provider-neutral handoff context 和 digest。 +7. 在一个 DB transaction 中按 `idempotencyKey` 插入 target `AgentSession(status=initializing)` 和 `Handoff(status=reserved)`;此时不推进 active pointer。 +8. 在 transaction 外调用目标 provider runtime。内部 `sessionId` 参数使用 target `agentSessionId`,让现有 adapter 的 process map、abort 和 native-id resolution 都按 segment 隔离。 +9. `ChatSessionWriter` 捕获目标 provider-native id 后,在一个 transaction 中保存 handle、标记 target active、source historical、写入 `provider_switched` boundary event、推进 `active_agent_session_id` 并增加 conversation version。 +10. 激活前产生的 provider events 暂存在小型 buffer;激活成功后再按 conversation id 发布,避免失败 target 污染可见 timeline。 +11. 如果 native id 建立前失败,target/handoff 标记 failed,source 保持 active,active pointer 不变。 +12. 如果 native id 已建立但首个 turn 后续失败,切换已经成功;保留 target active,只把该 turn 标记 failed。 +13. 相同 `idempotencyKey` 的重试返回同一 target/run,绝不创建第二个 native session。 + +目标状态至少应包括: + +```text +draft -> initializing -> active -> historical -> archived + \-> failed +``` + +## Runtime、WebSocket 与权限改造 + +### ChatRunRegistry + +当前 registry 按公开 app `sessionId` 键控。改造后应: + +- `runsByAgentSessionId`: 精确路由 provider runtime。 +- `activeRunByConversationId`: 执行“一个 conversation 只允许一个 foreground run”的产品规则。 +- run event 带 `conversationId`、`agentSessionId`、`runId` 和 `segmentEpoch/conversationVersion`。 +- reconnect replay 必须同时匹配 run/epoch,不能只凭会在新 run 重置的 `seq`。 + +### ChatSessionWriter + +Writer 应同时持有: + +```text +conversationId # 对外 sessionId,URL/前端 store key +agentSessionId # 对内 runtime owner +provider +providerSessionId # provider-native handle +runId +``` + +对外仍发送 `sessionId=conversationId`,同时增加 `agentSessionId`。`setSessionId(nativeId)` 只更新 target agent-session row。 + +### ProviderRuntimeContext + +`resolveProviderSessionId` 应改为: + +```text +resolveProviderSessionId(agentSessionId, expectedProvider) +``` + +并强制验证 row.provider 与 expected provider 相同。`assignProviderSessionId` 合并 watcher duplicate 时也必须按 `(provider, providerSessionId)` 匹配;当前只按 native id 查询的逻辑应一并收紧。 + +### Abort 与 permission + +- abort 地址是 `agentSessionId`,不是当前 UI focus 或 conversation active pointer。 +- 现有 `chat.abort` 在 adapter 返回 boolean 后立即合成 complete;切换前应升级为可等待 settlement 的 `AbortResult`。 +- permission request 增加 `agentSessionId/runId`。 +- permission response 校验请求仍属于该 run;过期 request 返回 `STALE_PERMISSION_REQUEST`。 +- target provider 从不继承 source pending permission。 + +## Handoff context 设计 + +Handoff 不是客户端文件附件,不能经过当前“只允许 upload store 文件”的 attachment trust boundary。应定义 server-trusted `HandoffContext`,只由服务端 curator 创建。 + +建议包含: + +1. 用户目标和约束。 +2. 已完成的关键结论与决策。 +3. 最近的 user/assistant turns。 +4. 工具调用的名称和短结果摘要。 +5. 修改过的文件、测试结果和未解决问题。 +6. source provider、boundary seq 和 digest,用于审计,不作为模型指令。 + +必须排除: + +- reasoning/thinking 原文。 +- raw tool input 中可能存在的 secret。 +- permission request/response。 +- 大段 shell 输出和重复 stream delta。 +- target provider 不理解的 source runtime metadata。 + +建议预算: + +```text +handoffBudget = min(64k tokens, targetContextWindow * 30%) +``` + +超预算时保留首个目标、最近 turns 和 unresolved work;中段先结构化摘要,再截断低价值 tool output。不要像简单 tail truncation 那样丢掉任务目标。 + +对只接受单字符串 prompt 的 CLI adapter,可使用可识别 envelope: + +```text + +... + + + +继续完成剩余测试 + +``` + +Canonical timeline 是 UI 的权威来源,因此这个注入块不会被重复显示成用户消息。Provider history normalizer 仍应识别并剥离 envelope,作为对账 fallback。 + +## History、usage、archive 与 watcher + +### History + +`GET /api/providers/sessions/:id/messages` 应迁移为 conversation-aware service: + +1. 通过 conversation id 取全部 segments。 +2. 优先读 canonical events 并按 conversation seq 分页。 +3. 对尚未导入的 legacy segment,调用其 provider history adapter,标记 agentSessionId 后导入/合并。 +4. 切换边界作为显式 event 展示。 +5. 不重复展示注入给 target 的 handoff history。 + +### Token usage + +Usage 仍按 agent segment/provider 读取,再返回: + +```json +{ + "total": {}, + "segments": [ + { "agentSessionId": "...", "provider": "claude", "usage": {} }, + { "agentSessionId": "...", "provider": "codex", "usage": {} } + ] +} +``` + +### Archive/delete + +- archive conversation:隐藏根记录,保留所有 segments/native handles。 +- restore conversation:恢复根记录和 active pointer。 +- force delete:逐 segment 使用对应 provider 的 deletion policy;OpenCode 共享 DB 不能因为删除一个 segment 而删除整个 DB。 +- 若以后允许删除单 segment,不能删除 active segment,除非显式选择 rollback target。 + +### Session synchronizer + +外部 CLI 新发现的 provider session 仍创建一个新的 conversation + 初始 segment。App 发起的 target segment 则通过 writer 回调认领 native id。Watcher duplicate 合并必须同时验证: + +```text +provider matches +native id matches +target segment is initializing/active +project path matches +``` + +仅凭 “同 provider + 同 cwd 下最新 pending row” 是启发式规则;有并发 session 时可能误认领。无法从 artifact 获得 creation nonce 的 provider,应该延迟 watcher 广播并等待 runtime mapping,而不是抢先绑定不确定的 row。 + +## Frontend 设计 + +1. URL 和 sidebar 继续只使用 `conversationId`,切换后不导航到新 ID。 +2. 在已有 conversation 的 composer 中也展示 provider/model selector。 +3. 同 provider 选 model:调用当前 active agent-session model API。 +4. 选不同 provider:只更新 `draftTargetProvider/model`,显示 “下一条消息将由 Codex 继续” 的状态;尚不创建 native session。 +5. 下一次发送走 `chat.switch-and-send`。 +6. 成功后插入可见边界:`Claude -> Codex · gpt-5.4`。 +7. Sidebar logo 显示 active provider;详情菜单可列出 provider lineage 和每段 usage。 +8. `selectedSession.__provider` 改为服务端返回的 active provider,而不是 conversation 的永久 provider。 +9. session store 仍以 conversationId 分槽;每条 NormalizedMessage 增加 `agentSessionId`。 +10. reconnect 订阅携带 `conversationVersion + runId + lastSeq`。 + +Queued message 必须保存并发送: + +```text +conversationId +expectedAgentSessionId +expectedConversationVersion +targetProvider/model (若它本身是 switch draft) +clientMessageId +``` + +如果 active segment 已改变,服务端返回 `STALE_AGENT_SESSION`;不能悄悄把旧队列投递给新 provider。 + +## 推荐模块边界 + +按当前 backend module 规范,conversation orchestration 不应塞进 provider route 或 WebSocket route。建议: + +```text +server/modules/conversations/ + index.ts + conversation.routes.ts # 只校验 transport + 调 service + repositories/conversation.repository.ts + services/conversation.service.ts + services/provider-switch.service.ts + services/handoff-context.service.ts + services/conversation-timeline.service.ts + tests/ +``` + +Provider adapter 继续只负责 native run/resume/abort 和 native history normalization。WebSocket handler 只解析命令并调用 `provider-switch.service` 或普通 chat orchestration service。跨模块只通过各模块 `index.ts`。 + +被 WebSocket、providers、conversations 共同使用的 `ConversationId`、`AgentSessionId`、`ConversationEvent`、`ProviderSwitchInput/Result` 应按仓库规范放到 `server/shared/types.ts`,并有完整约束注释;不要在各模块重复定义。 + +## 迁移与上线顺序 + +### Phase 1: 只拆身份,不开放切换 + +1. 创建 `conversations`,给当前 `sessions` 增加 segment 字段。 +2. 每个 legacy row 创建 `conversation_id = session_id` 的根记录。 +3. 原 row 成为 ordinal 1,`active_agent_session_id = session_id`。 +4. URL、REST response 和 WebSocket 对外行为保持不变。 +5. 运行 registry、writer、permission 和 usage 内部改用 agentSessionId。 + +### Phase 2: Canonical timeline + +1. 增加 conversation event store。 +2. 导入 legacy provider histories。 +3. live gateway 写 semantic events,complete 后与 provider transcript 对账。 +4. history API 改为统一 timeline,保持现有分页 response 兼容。 + +### Phase 3: Handoff backend + +1. 实现 completed-turn boundary cursor。 +2. 实现 curator、预算、digest 和 idempotent handoff。 +3. 实现两阶段 `chat.switch-and-send`。 +4. 先用 feature flag 仅开放 Claude <-> Codex,再覆盖 Cursor/OpenCode。 + +### Phase 4: UI 与兼容清理 + +1. 已有 session 显示 provider selector 和 switch boundary。 +2. queue/reconnect/export/usage 改为 segment-aware。 +3. 老 `/api/providers/sessions/*` route 保留兼容代理,再逐步迁移到 `/api/conversations/*`。 +4. 最后再考虑把 SQL `sessions` 物理重命名为 `agent_sessions`;这不是功能前置条件。 + +## 必须覆盖的测试 + +1. Migration 后所有旧 session ID、URL、名称、历史和 provider-native mapping 不变。 +2. Claude -> Codex 后 conversation ID 不变,生成两个不同 agent-session/native handle。 +3. 旧 Claude segment 仍可按其 handle 恢复或回滚。 +4. target native session 创建失败时 active pointer 不移动。 +5. 相同 idempotency key 重试不会创建第二个 target。 +6. 两个并发 switch 只有一个通过 version compare-and-swap。 +7. source run 活跃时默认拒绝切换。 +8. `cancelRunning=true` 只有在 acknowledged settlement 后继续。 +9. pending permission 被 source 终结,不出现在 target。 +10. stale queued message 不会发送给新 provider。 +11. 跨 segment history 顺序、tail pagination 和 boundary 正确。 +12. target history 不重复显示 handoff 注入文本。 +13. context curator 排除 reasoning、raw secret/tool input 和 permission events。 +14. reconnect replay 不会把旧 segment/run 的 seq 当作新 run seq。 +15. watcher race 只合并相同 provider/native id 的 duplicate。 +16. model 必须存在于 target provider catalog。 +17. usage 同时返回 conversation total 和 per-segment breakdown。 +18. archive/restore/delete 覆盖所有 segments,并遵守 OpenCode 共享 DB 规则。 + +## 不变量 + +- Conversation/Workspace identity 稳定,且显式拥有 1..N AgentSession。 +- `AgentSession.provider` 在 native handle 建立后不可变。 +- native handle 只在 `(provider, agentSessionId)` 范围内有效。 +- model 必须属于该 AgentSession 的 provider catalog。 +- message、run、abort、permission 和 usage 始终保留 agentSessionId。 +- UI focus 或 active pointer 不能改变一个正在运行的 run 的 owner。 +- 跨 provider 上下文只能来自规范化 timeline,不能直接复用 provider-native transcript/handle。 +- switch 激活失败时 source 仍是 active,且 target 不污染可见 timeline。 +- 所有 switch/retry 都有 idempotency key 和 optimistic version guard。 + +这套设计保留了 CloudCLI 当前稳定 app session ID 与 provider-native ID 分离的优点,并把 1:1 映射提升为 Paseo 所采用的稳定任务容器到多个 provider-bound agent session 的 1:N 映射。 diff --git a/docs/pi-provider-integration-plan.md b/docs/pi-provider-integration-plan.md new file mode 100644 index 0000000000..284d35aed6 --- /dev/null +++ b/docs/pi-provider-integration-plan.md @@ -0,0 +1,1063 @@ +# Pi Provider 集成方案(架构修订版) + +> 状态:待实施 +> +> 基线:基于当前仓库已有的 provider、WebSocket、Agent API、session、models、auth、skills、MCP 和 token usage 行为制定。 +> +> 原则:只解决当前已经存在的功能契约及其架构问题,不为尚未存在的产品功能预留抽象。 + +## 1. 结论 + +Pi 不应作为“照抄 OpenCode 的第 5 套 adapter + 若干中央分支”接入。 + +最终方案分成两部分: + +1. 先修正 provider 公共接缝,使 registry 成为 provider 描述、能力和 facet 的唯一真相。 +2. 再通过 Pi 自带的 RPC 协议和共享 `PiSessionStore` 实现 Pi provider。 + +必须保留当前已经正确工作的 application 层机制: + +- app session ID 与 provider-native session ID 分离。 +- `ChatSessionWriter` 捕获并持久化 session mapping。 +- `ChatRunRegistry` 保证一个 session 同时只有一个 run。 +- live event 使用单调 `seq`,支持 reconnect replay。 +- frontend 永远只接触 app session ID。 +- duplicate `complete` 被防御性丢弃。 + +必须修正的核心问题: + +- provider 差异仍泄漏在中央 capability、watcher、token usage、Agent API、server wiring 和 frontend state 中。 +- `IProvider` 强迫所有 provider 实现并不支持的 facet。 +- runtime 使用 `AnyRecord`、`unknown` 和隐式生命周期约定。 +- runtime 与 gateway 同时拥有终态。 +- provider-native session identity 未完整包含 provider。 +- 同一份 native session 数据被 history、sync、model 和 usage 重复解析。 + +Pi runtime 必须使用: + +```bash +pi --mode rpc +``` + +不使用: + +```bash +pi -p --mode json +``` + +RPC 已提供当前集成所需的结构化能力:`get_state`、`prompt`、`abort`、`get_available_models`、`get_entries`、`get_tree`、`get_commands`、`get_session_stats` 和可靠终态 `agent_settled`。 + +## 2. 范围 + +### 2.1 本期范围 + +- WebSocket live chat。 +- `/api/agent` 的 provider runtime 调度。 +- abort。 +- reconnect 和 replay。 +- app/provider session 映射。 +- session history 和 tail paging。 +- session filesystem watcher 和 synchronizer。 +- models、default model 和 reasoning effort。 +- auth/installation status。 +- token usage。 +- skills discovery、安装和删除。 +- 当前 MCP 接口中的 capability 表达。 +- frontend provider、model、effort 和 permission state。 + +### 2.2 非目标 + +- 不实现 Pi extensions 与本项目 MCP 的转换层。 +- 不实现新的 credential 管理 UI。 +- 不实现 Pi 长驻 daemon 或 process pool。 +- 不增加当前产品不存在的分支管理、session fork UI 或 extension 交互 UI。 +- 不把 provider 架构重写扩大到与 Pi 无关的 Git workflow 行为。 +- 不一次性重写现有四个 provider 的全部 runtime;迁移通过兼容 adapter 分阶段完成。 + +## 3. 第一性原理与系统不变量 + +Provider 模块的职责不是收集不同 CLI 的文件,而是吸收外部 CLI 差异,使 application 层只理解本项目自己的语义。 + +### 3.1 身份不变量 + +- `session_id` 是稳定的 app-facing ID。 +- provider-native identity 是 `(provider, provider_session_id)`。 +- app ID 和 provider ID 即使字符串相同,仍然是两个语义角色。 +- runtime 第一次获得 native ID 时必须显式 bind,不能因为字符串相同而绕过 DB mapping。 + +### 3.2 运行不变量 + +- 一个 app session 同时最多一个 active run。 +- 每个 run 拥有独立 `runId`。 +- abort 针对 `runId`,不能只依赖 session ID。 +- provider runtime 只产生非终态 event,并返回 outcome。 +- application 层是 `complete` 的唯一生产者。 +- reconnect replay 的 `seq` 由 application 层分配,provider 不参与。 + +### 3.3 数据不变量 + +- live stream 和 persisted session 是两种协议,不能假设事件结构相同。 +- 同一份 provider-native session artifact 只解析一次。 +- history、active model、usage 和 synchronizer 必须使用同一个解析结果。 +- 正在写入的 JSONL 尾部半行不能让整个 session 读取失败。 + +### 3.4 能力不变量 + +- 已知 provider 但缺少某个 facet,应返回 `PROVIDER_CAPABILITY_UNSUPPORTED`。 +- 未注册 provider 应返回 `UNSUPPORTED_PROVIDER`。 +- “不支持”不能通过空成功结果伪装为“支持但没有数据”。 +- frontend 行为由 backend provider descriptor 驱动;品牌名称和图标可以保留静态映射。 + +## 4. 当前架构判断 + +### 4.1 应保留的模块 + +`providerRuntimeService` 已经形成有效的 application/provider 接缝:它通过 registry 选择 runtime,并注入 model/session lookup,避免 provider runtime 反向解析 registry。 + +`ChatSessionWriter + ChatRunRegistry` 已经形成有效的 application/transport 接缝: + +- `session_created` 和 `setSessionId()` 被转换成 DB mapping。 +- provider-native ID 被改写成 app ID。 +- event 被排序、缓存和 replay。 +- active run 被集中管理。 +- terminal event 被去重。 + +这些行为应继续由 application 层拥有。 + +### 4.2 必须修正的模块 + +| 问题 | 当前形态 | 目标形态 | +|---|---|---| +| Registry | 只负责实例查找 | provider descriptor、facet 和能力的唯一真相 | +| Capabilities | 中央静态矩阵 | provider descriptor 声明行为能力,facet 存在性表达结构能力 | +| Runtime | `command + AnyRecord + unknown writer` | typed request、typed event sink、typed outcome | +| Terminal lifecycle | runtime、process exit、gateway 都可能 complete | coordinator 唯一完成 run | +| Abort | provider 自建 `Map` | coordinator 管理 `runId + AbortController` | +| Session identity | 部分 native lookup 不带 provider | 所有 native lookup 使用 `(provider, providerSessionId)` | +| Token usage | 中央按 provider 分支,默认落到 Claude | optional provider usage facet | +| Model cache | 中央 `UNCACHED_PROVIDERS` | model facet 声明 cache policy | +| Watch roots | 中央硬编码 provider 路径 | synchronizer 提供 watch targets | +| Scan cursor | 所有 provider 共用一个 cursor | per-provider cursor | +| Session notification | provider/watcher 反向导入 WebSocket | application publisher + WebSocket adapter | +| Agent API | 注入四个 runner 并 `if/else` | 注入一个 generic run coordinator | +| Frontend model state | 每 provider 一份 state/setter | `Partial>` | + +当前 `IProvider` 看似统一,实际上是浅模块:调用者仍需在多个位置了解每个 provider 的差异。目标是把复杂度收回 provider module,使新增 provider 的行为变化集中在 provider 自己的目录和一次 registry 注册中。 + +## 5. 目标架构 + +```text +HTTP/SSE routes WebSocket chat + | | + +---------- transport adapters --------+ + | + ProviderRunCoordinator + validation / identity / lifecycle / terminal + | + ProviderRegistry + descriptor + required/optional provider facets + | + concrete provider module + native RPC / CLI / paths / files / conversion +``` + +依赖方向: + +```text +transport -> application -> providers -> external CLI/files +``` + +禁止的依赖方向: + +```text +providers -> websocket +providers -> HTTP/SSE routes +provider adapter -> ProviderRegistry +``` + +跨 `server/modules/*` 的调用必须通过对应模块的 `index.ts`。所有新增 backend 文件必须是 TypeScript。Route 只负责解析、验证、调用 application module 和转换响应。 + +## 6. Provider 定义和能力模型 + +当前产品中的 provider 都必须支持 runtime、models、auth、sessions 和 synchronizer。这些保留为 required facet。当前确实可能不支持的 MCP、skills 和 token usage 改为 optional facet。 + +建议目标形态: + +```ts +type ProviderDefinition = { + readonly descriptor: ProviderDescriptor; + readonly runtime: IProviderRuntime; + readonly models: IProviderModels; + readonly auth: IProviderAuth; + readonly sessions: IProviderSessions; + readonly sessionSynchronizer: IProviderSessionSynchronizer; + readonly skills?: IProviderSkills; + readonly mcp?: IProviderMcp; + readonly usage?: IProviderTokenUsage; +}; + +type ProviderDescriptor = { + readonly id: LLMProvider; + readonly permissionModes: readonly PermissionMode[]; + readonly defaultPermissionMode: PermissionMode; + readonly supportsImages: boolean; + readonly supportsFiles: boolean; + readonly supportsAbort: boolean; + readonly supportsPermissionRequests: boolean; + readonly supportsEffort: boolean; +}; +``` + +结构能力必须从 facet 是否存在派生: + +- `supportsTokenUsage = Boolean(provider.usage)`。 +- `supportsMcp = Boolean(provider.mcp)`。 +- `supportsSkills = Boolean(provider.skills)`。 + +不能同时维护 facet 和第二份手写的 `supportsX` 真相。 + +Registry 负责: + +- `listProviders()`。 +- `resolveProvider(provider)`。 +- `requireFacet(provider, facet)`。 +- 注册时校验 descriptor,例如 default permission 必须存在于 permission modes 中。 +- 生成 frontend capability response。 + +中央 routes、commands、watcher 和 services 不再包含 provider switch。 + +## 7. Typed Runtime 接缝 + +共享类型应放入 `server/shared/types.ts`,class contract 放入 `server/shared/interfaces.ts`,并遵循仓库的分组和文档注释规则。 + +```ts +type ProviderSessionReference = { + appSessionId: string; + provider: LLMProvider; + providerSessionId: string | null; + projectPath: string | null; + artifactPath: string | null; +}; + +type ProviderRunRequest = { + runId: string; + command: string; + session: ProviderSessionReference; + cwd: string; + model?: string; + effort?: string; + permissionMode: PermissionMode; + attachments: AttachmentDescriptor[]; + signal: AbortSignal; +}; + +type ProviderRunOutcome = + | { status: 'completed'; exitCode: 0 } + | { status: 'aborted'; exitCode: number } + | { status: 'failed'; exitCode: number; error: ProviderRunError }; + +interface IProviderEventSink { + bindSession(binding: { + providerSessionId: string; + artifactPath?: string | null; + }): void; + + emit(event: ProviderNonTerminalEvent): void; +} + +interface IProviderRuntime { + run( + request: ProviderRunRequest, + sink: IProviderEventSink, + ): Promise; +} +``` + +`ProviderNonTerminalEvent` 在类型层排除 `complete` 和 `session_created`。Native session binding 只能通过 `bindSession()` 完成。 + +### 7.1 Coordinator 生命周期 + +```text +validate provider and capability + -> resolve one ProviderSessionReference + -> create runId and AbortController + -> register active run + -> runtime.run(request, sink) + -> receive outcome + -> coordinator emits exactly one complete + -> retain replay buffer + -> evict completed run after retention window +``` + +### 7.2 Abort 生命周期 + +```text +chat.abort(appSessionId) + -> resolve current runId + -> AbortController.abort() + -> provider maps signal to native abort + -> runtime returns aborted outcome + -> coordinator emits exactly one aborted complete +``` + +`ChatRunRegistry` 的 duplicate complete 防护继续保留,但只作为 invariant assertion 和兼容期保护,不作为正常控制流。 + +### 7.3 现有 provider 的迁移 + +现有四个 JavaScript runtime 不要求与 Pi 同时重写。先增加 `LegacyProviderRuntimeAdapter`: + +- 将 typed request 转换为当前 options。 +- 将旧 writer event 转换为 typed sink event。 +- 拦截旧 runtime 的 `complete/session_created`。 +- 将旧 `abort(sessionId)` 接到 request 的 `AbortSignal`。 +- 根据旧 runtime resolve/reject 和捕获的终态构造 outcome。 + +Pi 直接实现新接口,不创建新的 JavaScript runtime。 + +## 8. Session Identity 和数据库 + +### 8.1 组合唯一性 + +增加 migration: + +```sql +CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_provider_native_id +ON sessions(provider, provider_session_id) +WHERE provider_session_id IS NOT NULL; +``` + +迁移前必须检查并合并可能存在的重复 `(provider, provider_session_id)` 行,不能直接创建索引并假设数据干净。 + +### 8.2 Repository 接口修正 + +以下操作必须接收 provider: + +```ts +assignProviderSessionId( + appSessionId: string, + provider: LLMProvider, + providerSessionId: string, +): void; + +getSessionByProviderSessionId( + provider: LLMProvider, + providerSessionId: string, +): SessionRow | null; +``` + +Duplicate merge SQL 必须包含 `provider = ?`,避免不同 provider 的相同 native ID 被错误合并。 + +`ProviderSessionReference` 由 application session module 根据 DB row 构造一次。History、model、usage 和 runtime 不再各自猜测传入的是 app ID 还是 native ID。 + +### 8.3 Pi mapping + +Pi 使用 `--session-id `,但在 `get_state` 成功后仍必须调用: + +```ts +sink.bindSession({ + providerSessionId: state.sessionId, + artifactPath: state.sessionFile, +}); +``` + +绑定必须发生在第一条 live event 之前。 + +Pi 不使用 `findLatestPendingAppSession()` 的时间启发式。`get_state` 已提供精确 session ID 和 session file,可以确定性绑定。 + +## 9. Pi Provider 内部设计 + +### 9.1 文件结构 + +```text +server/modules/providers/list/pi/ + index.ts + pi.provider.ts + pi-paths.provider.ts + pi-rpc-client.provider.ts + pi-session-store.provider.ts + pi-runtime.provider.ts + pi-models.provider.ts + pi-auth.provider.ts + pi-skills.provider.ts + pi-sessions.provider.ts + pi-session-synchronizer.provider.ts + pi-token-usage.provider.ts +``` + +不创建 `pi-runtime.provider.js` 或其他新的 backend JavaScript 文件。 + +`index.ts` 只导出 providers 模块注册 Pi 所需的公共定义。`PiRpcClient`、`PiPaths` 和 `PiSessionStore` 是 Pi module 的内部接缝,不暴露给其他 feature module。 + +### 9.2 内部依赖 + +```text +PiProvider + |-- PiPaths + |-- PiRpcClientFactory + |-- PiSessionStore + |-- sessions/history + |-- session synchronizer + |-- active model + `-- token usage +``` + +`PiRpcClientFactory` 接受 process spawn dependency。生产使用真实 subprocess adapter,测试使用 fake process adapter。 + +## 10. PiPaths + +`PiPaths` 是 Pi executable、agent directory 和 session roots 的唯一解释者。 + +解析范围: + +- 应用可配置的 `PI_CLI_PATH`。 +- `PI_CODING_AGENT_DIR`。 +- `PI_CODING_AGENT_SESSION_DIR`。 +- Pi `settings.json.sessionDir`。 +- 默认 `/sessions`。 + +运行时不传 `--session-dir `。Pi 自己负责配置优先级和 cwd 目录编码;当前 session artifact 以 `get_state.sessionFile` 为权威。 + +Synchronizer 和 watcher 通过 `PiPaths.getSessionRoots()` 获得实际 root。所有路径在进入 filesystem 操作前 resolve/normalize。 + +## 11. PiRpcClient + +`PiRpcClient` 隐藏以下实现复杂度: + +- spawn `pi --mode rpc`。 +- stdin JSONL serialization。 +- stdout 任意 chunk 分割和多行合并。 +- request ID 生成与 response correlation。 +- unsolicited event 分发。 +- stderr 收集。 +- invalid JSON 和 unknown response 处理。 +- process close 时 reject 所有 pending request。 +- stdin backpressure 和 EPIPE。 +- graceful close。 + +一次 turn 使用一个 RPC process。完成后关闭 stdin,让 Pi 正常退出。不引入常驻 process pool。 + +Runtime 和 probe 使用相同 extension policy: + +```bash +--no-extensions +``` + +原因是当前应用没有 Pi extension UI request handler。若加载会发起交互请求的 extension,RPC 可能无限等待。该 flag 不表示未来永远不支持 extension,只定义本期 runtime 的确定行为。 + +## 12. Pi Runtime + +### 12.1 Spawn + +```bash +pi --mode rpc \ + --session-id \ + --no-extensions \ + [--provider ] \ + [--model ] \ + [--thinking ] \ + [--tools ] +``` + +Process `cwd` 使用 request 的已验证项目路径。不向 shell 拼接 command string,所有 flags 使用 argv 数组。 + +### 12.2 状态机 + +```text +IDLE + -> SPAWNING + -> REQUESTING_STATE + -> BINDING_SESSION + -> PROMPTING + -> STREAMING + -> SETTLED + -> CLOSED +``` + +失败状态: + +```text +any non-terminal state + -> ABORTING -> ABORTED + -> FAILED +``` + +执行顺序: + +1. Spawn RPC process。 +2. 发送 `get_state`。 +3. 校验 `sessionId`、`sessionFile`、model 和 thinking level。 +4. 在任何 live event 之前调用 `sink.bindSession()`。 +5. 发送 `prompt`。 +6. 转换并发送非终态 event。 +7. 收到 `agent_settled` 后将 run 标记为 settled。 +8. 关闭 stdin 并等待 process 正常退出。 +9. 返回 completed outcome。 + +`agent_end`、`turn_end` 或 exit code 0 都不能单独作为成功终态。`agent_settled` 之前发生的 process close 返回 failed outcome。 + +### 12.3 Event 映射 + +| Pi RPC event | App normalized event | +|---|---| +| `text_delta` | `stream_delta` | +| `thinking_delta` | `thinking` | +| `tool_execution_start` | `tool_use` | +| `tool_execution_end` | `tool_result` | +| assistant `stopReason: error` | `error` | +| retry start/update/end | `status` | +| `turn_end` | `stream_end` 和 usage snapshot | +| `agent_settled` | 返回 completed outcome,不直接 emit `complete` | + +映射函数必须是 pure function,并用真实 RPC fixture 测试。Unknown event 记录 debug 信息后忽略;已知 event 的非法 payload 返回 protocol error,不能伪造成成功。 + +### 12.4 Abort + +`request.signal` 触发后: + +1. 向当前 RPC client 写入 `{ "type": "abort" }`。 +2. 等待 `agent_settled` 或一个有上限的 graceful shutdown window。 +3. graceful window 超时后终止该 run 的 process。 +4. 返回 aborted outcome。 + +Process ownership 按 `runId` 管理,不建立 `Map`。 + +## 13. PiSessionStore + +Pi 的 live RPC event 和 session JSONL entry 不是同一协议。History 不能复用 runtime event parser。 + +`PiSessionStore` 提供一个小接口: + +```ts +type PiSessionStore = { + load(session: ProviderSessionReference): Promise; +}; +``` + +`PiSessionSnapshot` 是一次解析后的不可变结果,包含: + +- header/session ID/cwd/version。 +- artifact path。 +- active branch entries。 +- current model。 +- latest usable token usage。 +- created/updated timestamps。 +- 可转换为 history 的内容节点。 + +### 13.1 JSONL 读取规则 + +- 按行解析完整 JSON。 +- 文件末尾最后一个非完整行视为 concurrent write,忽略而不是报错。 +- 中间行损坏视为 artifact corruption,返回稳定错误并包含行号。 +- 校验 header `type: "session"`、session ID、cwd 和支持的 version。 +- 不支持的未来 session version 显式报错,不能静默误读。 + +### 13.2 Active branch + +- 以最后一个有效 entry 作为当前 leaf。 +- 根据 `parentId` 回溯到 root。 +- 只转换 active branch,不显示已放弃的 sibling branch。 +- 检测 cycle、重复 ID 和丢失 parent。 +- 应用 Pi compaction 和 branch summary 语义,避免把已压缩内容重复展示。 + +### 13.3 History 转换 + +转换范围: + +- user text。 +- assistant text。 +- thinking content。 +- tool call 和 tool result。 +- `display: true` 的 custom message。 +- compaction 和 branch summary 的可显示内容。 + +明确忽略: + +- hidden custom message。 +- 非 active branch entry。 +- 仅用于 session bookkeeping 的 entry。 + +稳定 message ID 使用: + +```text +: +``` + +分页继续使用现有 `sliceTailPage`,保持当前 tail paging 契约。 + +### 13.4 Active model + +从 active branch 最后一个 `model_change` 取得当前模型,而不是读取文件中第一个 model event。 + +Frontend model value 使用: + +```text +/ +``` + +内部保留结构化 `{ provider, modelId }`,只在 transport edge 序列化,避免通过任意 `/` 字符串反复猜测。 + +### 13.5 Token usage + +取 active branch 最后一个满足以下条件的 assistant usage: + +- message 没有 error stop reason。 +- turn 没有 aborted。 +- usage 字段完整有效。 + +Pi usage facet 只消费 `PiSessionSnapshot`,不重新读取 JSONL。 + +### 13.6 Synchronizer + +Synchronizer 从同一个 snapshot 提取 session metadata 并 upsert: + +- provider = `pi`。 +- provider session ID。 +- project/cwd。 +- artifact path。 +- current model。 +- created/updated time。 + +## 14. Models + +模型目录使用 RPC `get_available_models`,不直接读取 `models.json`。 + +原因: + +- 配置文件不等于 runtime 实际目录。 +- built-in、自定义 provider 和 credential 状态可能来自不同来源。 +- probe 与 runtime 使用同一组 flags 才能保证 UI 中可选模型可以实际运行。 + +规则: + +- canonical value 为 `/`。 +- default 来自 RPC `get_state.model`。 +- 只有 `reasoning: true` 的模型提供 thinking effort。 +- thinking level 限定为 Pi 当前接受的 `off|minimal|low|medium|high|xhigh|max`。 +- Pi model facet 声明 `cachePolicy: none`;不向中央 `UNCACHED_PROVIDERS` 添加 Pi。 +- runtime 收到 canonical model 后拆成独立 `--provider` 和 `--model` argv。 + +Model catalog probe 和 runtime 都使用 `--no-extensions`,避免 catalog/runtime policy 不一致。 + +## 15. Auth + +Installed 定义:配置后的 Pi executable 能成功执行 `--version`。 + +- 不使用 shell `which pi`。 +- 不从用户输入拼接 shell command。 +- 支持 `PI_CLI_PATH`,否则按 process PATH 解析 executable。 + +Authenticated 定义:使用与 runtime 相同配置启动 RPC probe,并成功获得至少一个当前可用模型。 + +- 不读取或猜测 `auth.json`。 +- 不直接检查某个固定 API key 环境变量。 +- 不假设 credential 只能来自文件。 +- “未安装”和“未认证”是正常状态,`getStatus()` 不为此抛异常。 +- protocol corruption、spawn permission error 等异常与正常未认证状态区分。 + +## 16. Permissions + +Pi 没有本项目所表达的逐工具确认权限系统,只有 tool allowlist。因此只暴露两个行为不同的模式: + +| UI mode | Pi argv | 语义 | +|---|---|---| +| `plan` | `--tools read,grep,find,ls` | 仅提供当前确认的只读工具集 | +| `bypassPermissions` | 不传 `--tools` | Pi 默认完整工具集 | + +Pi descriptor: + +```ts +permissionModes: ['plan', 'bypassPermissions']; +defaultPermissionMode: 'bypassPermissions'; +supportsPermissionRequests: false; +``` + +不暴露三个行为相同的 `default/acceptEdits/bypassPermissions`。Frontend 在切换 provider 时,如果当前 mode 不在 capability list 中,切换到该 provider 的 default。 + +`--tools` 是 agent tool allowlist,不是 OS sandbox。UI 和错误文案不能把它描述为操作系统级安全隔离。 + +## 17. Skills + +权威 discovery 使用 RPC `get_commands`,过滤: + +```ts +command.source === 'skill' +``` + +Pi skill invocation 格式为: + +```text +/skill: +``` + +不能照抄 OpenCode 的 `/`。 + +Managed write root 使用 `PiPaths.agentDir/skills`。Add/remove 继续遵循当前 skills 功能的名称验证、路径约束和 `SKILL.md` 格式,不允许目录穿越。 + +## 18. MCP + +Pi 当前没有本项目 MCP facet 所需的 provider-native 配置和写入语义。因此最终定义中 Pi 不提供 `mcp` facet。 + +结果: + +- capability endpoint 返回 `supportsMcp: false`。 +- frontend 不为 Pi 请求或展示可编辑 MCP 列表。 +- 手工调用 Pi MCP route 返回 `PROVIDER_CAPABILITY_UNSUPPORTED`。 + +迁移期间若现有 `IProvider` 尚未改成 optional facet,可临时使用共享的 `UnsupportedProviderMcpAdapter`: + +- `listServers()` 返回完整的 grouped empty shape,不返回裸 `[]`。 +- `listServersForScope()` 返回对应 scope 的空数组。 +- 所有 write 操作抛稳定 unsupported error。 + +完成 optional facet 迁移后删除该临时 adapter,不保留 `pi-mcp.provider.ts` 空实现。 + +## 19. Watcher、Synchronizer 和通知 + +### 19.1 Watch targets + +`IProviderSessionSynchronizer` 增加动态 watch target 查询: + +```ts +getWatchTargets(): Promise; +``` + +Watcher 从 registry 遍历 synchronizer 获取 roots,不维护中央 `PROVIDER_WATCH_PATHS`。 + +Pi watch targets 来自 `PiPaths.getSessionRoots()`,因此支持 env 和 settings 的当前配置。 + +### 19.2 Per-provider cursor + +将单例 `scan_state(id = 1)` 迁移为 provider 维度,例如: + +```sql +CREATE TABLE provider_scan_state ( + provider TEXT PRIMARY KEY, + last_scanned_at TEXT NOT NULL +); +``` + +每个 provider 在自身 synchronize 成功后独立推进 cursor。Pi 失败不能阻止 Claude/Codex/Cursor/OpenCode 推进,反之亦然。 + +### 19.3 Session notification + +Provider/session module 不直接导入 WebSocket。 + +定义 application-owned 的 session change publisher port: + +- production adapter 将 upsert 转换成 WebSocket `session_upserted`。 +- test adapter 在内存中记录 notification。 + +Synchronizer 只返回/upsert canonical app session ID,application module 决定是否通知 transport。 + +## 20. 两个 Runtime 入口 + +### 20.1 WebSocket + +WebSocket handler 负责: + +- transport payload 解析。 +- capability validation。 +- attachment 安全校验。 +- 建立或解析 app session。 +- 调用 `ProviderRunCoordinator.run()`。 +- subscribe/replay/abort transport response。 + +它不做 provider dispatch,也不直接操作 Pi RPC。 + +### 20.2 `/api/agent` + +Agent route 不再接收: + +```text +queryClaude +queryCursor +queryCodex +queryOpenCode +queryPi +``` + +只注入一个 generic coordinator/application interface。Provider validation 通过 registry 完成,执行路径中不出现逐 provider `if/else`。 + +Agent API 当前的 repository clone、stream/non-stream response 和 cleanup 语义保持不变;只替换 provider 选择、model default 和 runtime execution 部分。相关业务编排应逐步从 `agent.routes.ts` 提取到 agent application module,使 route 恢复类型检查并移除 `@ts-nocheck`。 + +Git module 当前只支持特定 provider 的既有行为不属于 Pi 接入范围,不因本计划自动增加 Pi。 + +## 21. Frontend + +### 21.1 Provider state + +将独立的: + +```text +claudeModel +cursorModel +codexModel +opencodeModel +``` + +改为: + +```ts +Partial> +``` + +统一处理初始化、localStorage、catalog validation 和 setter。Effort 已使用相似结构,model state 与之对齐。 + +### 21.2 Capability-driven behavior + +以下行为来自 backend capability response: + +- permission modes 和 default。 +- effort 是否显示。 +- token usage 是否可用。 +- MCP 是否可用。 +- skills 是否可用。 +- abort、images 和 files 是否可用。 + +Frontend 只保留 provider logo、展示名称等静态品牌映射。 + +### 21.3 Pi UI + +- 增加 `pi` provider type 和 brand metadata。 +- 增加 Pi logo。 +- model picker 展示 canonical Pi models。 +- reasoning model 才展示 effort。 +- permission picker 只展示 `plan` 和 `bypassPermissions`。 +- MCP 页面不显示 Pi 为可配置 provider。 +- skills command 显示 `/skill:`。 + +## 22. 实施阶段 + +### Phase 0:行为基线和 fixtures + +工作: + +- 为当前四个 provider 补充 characterization tests。 +- 固化 live event、mapping、resume、abort、history、usage 和 replay 契约。 +- 保存真实 Pi RPC event 和 session v3 JSONL fixtures,移除 credential 和用户路径。 + +完成条件: + +- 后续架构迁移可以用同一组 observable behavior tests 验证无回归。 + +### Phase 1:Registry 和 capability 收口 + +工作: + +- 引入 `ProviderDefinition/ProviderDescriptor`。 +- 将 MCP、skills、usage 改为 optional facet。 +- capability response 从 registry 派生。 +- model cache policy 移到 models facet。 +- provider routes 和 commands provider parser 改为 registry 驱动。 + +完成条件: + +- 新增一个测试 provider 只需注册一次,不需修改 capability、route parser 或 token service switch。 +- unknown provider 与 unsupported facet 返回不同稳定错误。 + +### Phase 2:Session identity、sync 和 watcher + +工作: + +- 增加 `(provider, provider_session_id)` 唯一约束。 +- 修正所有 native lookup 和 merge。 +- 引入 `ProviderSessionReference`。 +- watcher targets 改为 synchronizer 提供。 +- scan cursor 改为 per-provider。 +- 移除 providers 到 WebSocket 的反向 import。 + +完成条件: + +- 两个 provider 使用相同 native ID 时不会错误合并。 +- 单个 provider sync 失败不影响其他 provider cursor。 + +### Phase 3:Typed runtime 和 generic dispatcher + +工作: + +- 引入 typed run request、event sink、outcome。 +- 引入 `ProviderRunCoordinator`。 +- coordinator 成为唯一 terminal owner。 +- 为当前四个 runtime 增加 legacy compatibility adapter。 +- WebSocket 和 Agent API 使用 generic dispatcher。 + +完成条件: + +- `/api/agent` 不存在新增 `queryPi` 的需求。 +- provider runtime 不能通过类型发送 `complete`。 +- abort 由 runId/AbortSignal 驱动。 +- `agent.routes.ts` 的 provider dispatch 分支被删除。 + +### Phase 4:Pi 基础模块 + +工作: + +- 实现 `PiPaths`。 +- 实现并测试 `PiRpcClient`。 +- 实现并测试 `PiSessionStore`。 + +完成条件: + +- JSONL chunking、response correlation、abort 和 unexpected close 测试通过。 +- branch、compaction、partial tail 和 corrupted line 测试通过。 + +### Phase 5:Pi facets 和注册 + +工作: + +- runtime。 +- models。 +- auth。 +- sessions/history。 +- synchronizer/watch targets。 +- token usage。 +- skills。 +- provider descriptor 和 registry registration。 + +完成条件: + +- Pi 不需要中央 provider switch。 +- Pi 没有空 MCP 实现。 +- runtime 第一条 event 前已完成 session binding。 + +### Phase 6:Frontend 和端到端验收 + +工作: + +- generic model state。 +- Pi brand metadata/logo。 +- capability-driven permissions、effort、usage、MCP 和 skills。 +- real Pi smoke tests。 + +完成条件: + +- 新建 session、第二轮 resume、abort、reconnect、history refresh、sidebar sync 和 model restore 全部通过。 + +## 23. 测试矩阵 + +### 23.1 Registry 和 application contract + +- registry 枚举、解析和 unknown provider。 +- required/optional facet validation。 +- unsupported capability error。 +- capability response 与 facet 存在性一致。 +- generic Agent API dispatcher 不含 provider branch。 + +### 23.2 Runtime lifecycle + +- session mapping 先于第一条 live event。 +- 一个 session 拒绝第二个 active run。 +- runtime success 只产生一个 complete。 +- runtime throw/close 产生 failed complete。 +- abort 只终止目标 run。 +- abort 和 late native event 竞争仍只有一个 complete。 +- completed run 可按 `seq` replay。 +- buffer gap 回退 REST history。 + +### 23.3 Pi RPC + +- 一个 chunk 多行。 +- 一行跨多个 chunk。 +- stdout 尾部无换行。 +- response 与 event 交错。 +- 多个 pending request 按 ID 匹配。 +- malformed JSON。 +- stderr 不污染 stdout parser。 +- process unexpected close reject pending request。 +- RPC abort、graceful settle 和 force termination fallback。 +- retry 后以 `agent_settled` 完成。 + +### 23.4 Pi session store + +- valid v3 header。 +- 不支持版本。 +- 尾部半行。 +- 中间 corrupted line。 +- active leaf 和 parent traversal。 +- sibling branch discard。 +- missing parent、duplicate ID 和 cycle。 +- compaction 和 branch summary。 +- hidden/display custom message。 +- text、thinking、tool call/tool result。 +- stable message ID。 +- tail paging。 +- active branch 最后 model change。 +- 最后一个非 error/aborted usage。 + +### 23.5 Paths、models、auth 和 skills + +- `PI_CLI_PATH`。 +- custom `PI_CODING_AGENT_DIR`。 +- custom `PI_CODING_AGENT_SESSION_DIR`。 +- `settings.json.sessionDir`。 +- default session root。 +- canonical model ID round trip。 +- reasoning model effort。 +- no-extension probe/runtime policy 一致。 +- installed/uninstalled/authenticated/unauthenticated。 +- `/skill:` discovery。 +- skill path traversal rejection。 + +### 23.6 Database 和 sync + +- 不同 provider 可拥有相同 native ID。 +- 同 provider 重复 native ID 被唯一约束拒绝或确定性合并。 +- app/native ID 同值仍完成 mapping。 +- Pi 精确 binding 不使用 pending-session 时间启发式。 +- per-provider scan cursor。 +- watcher custom roots。 +- session upsert notification 不依赖 provider 直接导入 WebSocket。 + +## 24. 验证命令 + +按阶段先运行窄测试,再运行完整检查: + +```bash +node --import tsx --test +npm run build +npm run typecheck +npm run lint +npm test +``` + +真实 Pi smoke test 至少覆盖: + +1. RPC installation/model probe。 +2. 新建 session 并流式输出 text/thinking。 +3. tool start/end。 +4. 第二轮使用同一 app session resume。 +5. abort。 +6. 进程完成后立即读取 history。 +7. filesystem sync 后 sidebar 使用 app session ID。 + +## 25. 完成定义 + +只有同时满足以下条件,Pi provider 才算接入完成: + +- Pi 只在 provider 本地目录和 registry 注册点出现;generic 中央模块没有 `if (provider === 'pi')`。 +- WebSocket 和 Agent API 都通过统一 runtime coordinator。 +- Runtime 和 route 不新增 `@ts-nocheck`。 +- 所有新增 backend 文件为 TypeScript。 +- app/native session mapping 在第一条 live event 前持久化。 +- `(provider, provider_session_id)` 在 DB 和 repository 接口中均成立。 +- gateway/application coordinator 是 terminal event 的唯一所有者。 +- history、active model、usage 和 sync 共用 `PiSessionStore`。 +- Pi models/auth 使用 RPC probe,不猜测 `models.json/auth.json`。 +- Pi 只暴露真实 permission modes。 +- Pi MCP 被表达为 unsupported,而不是空成功实现。 +- per-provider scan cursor 和动态 watch roots 生效。 +- frontend model state 不再逐 provider 复制。 +- narrow tests、build、typecheck、lint、完整测试和 real Pi smoke tests 全部通过。 + +## 26. 主要风险与控制 + +| 风险 | 控制 | +|---|---| +| Pi RPC/session schema 随版本变化 | 保存真实 fixture、校验 session version、protocol error 显式失败 | +| 正在写入 JSONL 导致偶发解析失败 | 忽略尾部半行,中间损坏仍报错 | +| 大范围 runtime 迁移引入现有 provider 回归 | 先用 legacy adapter,按 observable contract 测试替换 | +| app/native mapping 与 watcher 竞争 | `get_state` 后立即 bind,DB transaction + provider-qualified merge | +| abort 与新 run 交错 | process 归属 `runId`,AbortSignal 只作用于当前 run | +| UI 展示无法兑现的权限语义 | descriptor 只暴露实际不同的两个 Pi mode | +| extension 发起无人处理的 RPC 交互 | runtime/probe 统一使用 `--no-extensions` | +| 中央遗漏 Pi 分支后静默走 Claude fallback | 删除中央 provider-specific fallback,使用 required facet dispatch | diff --git a/openspec/changes/add-pi-provider/.openspec.yaml b/openspec/changes/add-pi-provider/.openspec.yaml new file mode 100644 index 0000000000..e08b5f89a2 --- /dev/null +++ b/openspec/changes/add-pi-provider/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-03 diff --git a/openspec/changes/add-pi-provider/design.md b/openspec/changes/add-pi-provider/design.md new file mode 100644 index 0000000000..2cb15517cc --- /dev/null +++ b/openspec/changes/add-pi-provider/design.md @@ -0,0 +1,149 @@ +# Pi Provider 集成设计 + +## 背景 + +动机见 proposal.md - Why。本设计的核心约束是**范围切割**:只新增 Pi 一个 provider,沿用当前已存在的 `IProvider` 契约与中央装配模式,**不重构** provider 公共接缝。因此 Pi 会和现有 4 个 provider 一样,在若干中央文件里各留一处 provider 分支——这是本次刻意接受的现状,不是要修的问题。 + +两个来自用户的定向决策: +- 建造策略复用 Pi 官方实现(`@earendil-works/pi-coding-agent`),不自研 RPC 协议。 +- 依赖锁定到稳定发布版本,不用 `latest`/`^`。 + +## 证据登记 + +| 编号 | 标签 | 陈述 | 依据 | 风险 | +|---|---|---|---|---| +| E1 | `[CONFIRMED]` | provider 通过 `IProvider` 8 facet 契约装配,registry 是 `Record` | `server/modules/providers/provider.registry.ts`;`server/shared/interfaces.ts:47` | 低 | +| E2 | `[CONFIRMED]` | `IProvider` 强制 `mcp` facet(非 optional),Pi 必须提供一个实现 | `server/shared/interfaces.ts:47-56` | 中 | +| E3 | `[CONFIRMED]` | `LLMProvider` 是 4 元字符串联合,需加 `'pi'` | `server/shared/types.ts:69` | 低 | +| E4 | `[CONFIRMED]` | capabilities 是中央手写静态矩阵,逐 provider 一条 | `server/modules/providers/services/provider-capabilities.service.ts:37+` | 低 | +| E5 | `[CONFIRMED]` | token-usage 按 provider `if` 分支,未命中默认落 `.claude` | `.../provider-token-usage.service.ts:269/281/293/326` | 中 | +| E6 | `[CONFIRMED]` | watcher 路径为中央常量 `PROVIDER_WATCH_PATHS` | `.../sessions-watcher.service.ts:15` | 低 | +| E7 | `[CONFIRMED]` | scan cursor 为全局单例;任一 provider 失败则跳过游标推进,但各 synchronizer 独立执行 | `.../session-synchronizer.service.ts:49` | 中 | +| E8 | `[CONFIRMED]` | agent 路由逐 provider `if/else` 注入 `queryClaude/...`,文件头 `@ts-nocheck` | `server/modules/agent/agent.routes.ts:1,21-24,985-1018` | 中 | +| E9 | `[CONFIRMED]` | native session lookup 签名不含 provider | `server/modules/database/repositories/sessions.db.ts:179,263` | 高 | +| E10 | `[CONFIRMED]` | 现有 runtime 是 `.js`,其余 facet 是 `.ts` | `list/opencode/opencode-runtime.provider.js` 等 | 低 | +| E11 | `[CONFIRMED]` | Pi 提供 RPC 模式与官方客户端(`rpc-client.ts` / `AgentSession`),JSONL 严格以 `\n` 分隔,Node `readline` 不合规 | pi.dev/docs rpc + npm `@earendil-works/pi-coding-agent` | 中 | +| E12 | `[PENDING_VERIFY]` | Pi `agent_settled` 事件在目标锁定版本中存在且语义为「无 retry/compaction/follow-up 后的终态」 | 需在选定版本 changelog 与实测中确认 | 高 | +| E13 | `[PENDING_VERIFY]` | `get_state` 返回 `sessionId`/`sessionFile`/`model`;`get_available_models`/`get_commands` 可用 | 需对锁定版本实测 | 中 | +| E14 | `[PENDING_VERIFY]` | 前端每 provider 一份 model state(`claudeModel`/`cursorModel`/...)的具体位置与结构 | `src/stores/useSessionStore.ts` 等,需读确认 | 低 | + +- [x] 每条关于现存代码的陈述都已登记。 +- [x] 每条 `[INFERRED]` 都写出了推理依据(本表无 INFERRED)。 +- [x] 高风险结论(E9/E12)未仅依赖 INFERRED:E9 为 CONFIRMED,E12 标记 PENDING_VERIFY 待实测。 + +## 目标 / 非目标 + +**目标:** +- Pi 以 `IProvider` 契约完整接入,覆盖 spec 中全部 `pi-provider` 需求。 +- Pi runtime 复用 Pi 官方 RPC 客户端,不自研 JSONL 分帧/关联。 +- 依赖锁定稳定版本。 + +**非目标(本设计层面的边界,超出 proposal 已声明范围的部分):** +- 不新增 `(provider, provider_session_id)` 唯一约束,不改 `sessions.db.ts` 的 native lookup 签名(E9 现状保留)。 +- 不改 capabilities 为「从 facet 派生」;沿用静态矩阵(E4)。 +- 不做 per-provider scan cursor(E7 现状保留)。 +- 不引入 typed runtime / generic coordinator;Pi 走现有 `IProviderRuntime.run(command, options, writer, context)` 契约与 agent 路由分支(E8)。 +- 不消除 `@ts-nocheck`。 + +## 设计决策 + +**决策 1:Pi runtime 复用官方 RPC 客户端,通过 `pi --mode rpc` 子进程。** +理由:Pi 官方已实现 JSONL 分帧、请求关联、事件分发;E11 指出手写易踩 `readline` 不合规坑。复用官方客户端消除这一整类风险。 +替代方案:(a) 自研 `PiRpcClient`——被否,重复造轮子且承担协议维护;(b) `AgentSession` 进程内内嵌——被否,与现有 4 个 provider「spawn CLI 子进程」的运行模型不一致,会引入独立的生命周期/隔离语义,超出「照现有模式加一个 provider」的范围。 + +**决策 2:runtime 用 TypeScript 实现(`pi-runtime.provider.ts`),不产出新的 `.js`。** +理由:现有 `.js` runtime 是历史遗留(E10);新文件用 TS 可通过 typecheck、复用官方包类型。不改动现有 `.js`。 + +**决策 3:Pi 提供「不支持」语义的 mcp facet,而非把 mcp 改成 optional。** +理由:`IProvider` 当前强制 mcp(E2),改成 optional 属于被切掉的重构。Pi 的 mcp facet 读操作返回完整的分组空结构,写操作抛 `ERR-PROVIDER-CAPABILITY-UNSUPPORTED`。capabilities 矩阵里 Pi 手写 `supportsMcp:false`。 + +**决策 4:session 身份沿用现有 provider-less lookup,Pi native id 依赖其 UUID 唯一性。** +理由:改 lookup 签名与加唯一约束属于被切掉的重构(E9)。Pi 的 native session id 为 UUID,跨 provider 碰撞概率可忽略。此为**已知局限**,见风险表。 + +## 模块边界 + +| 模块 | 职责 | **不负责** | 输入 | 输出 | 依赖 | 状态归属 | +|---|---|---|---|---|---|---| +| `list/pi/PiPaths` | 解析 Pi 可执行文件、agent 目录、session 根 | RPC、文件解析、DB | env / settings | 路径 | fs | 无(纯解析) | +| `list/pi/PiRpcClient`(薄封装官方 client) | spawn `pi --mode rpc`、收发 RPC、事件分发 | 事件语义映射、终态判定 | argv / 请求 | 事件流 / 响应 | 官方包、PiPaths | 进程句柄(按 runId) | +| `list/pi/pi-runtime` | 运行状态机、事件映射、终态(agent_settled)、abort | 历史解析、DB 写 | run 请求 | 归一化事件 / outcome | PiRpcClient | 当前 run 进程归属 | +| `list/pi/PiSessionStore` | 解析 session JSONL 为不可变快照(一次解析) | RPC、DB 写 | session 文件 | `PiSessionSnapshot` | PiPaths、fs | 无 | +| `list/pi/pi-sessions` | 快照 → 归一化 history / 分页 | 磁盘扫描 | app session | history | PiSessionStore | 无 | +| `list/pi/pi-session-synchronizer` | 扫描 session 根 → upsert metadata | 通知 transport | 扫描触发 | upsert 计数 | PiSessionStore、sessions.db | DB session 行(与其他 provider 共享表) | +| `list/pi/pi-models` | `get_available_models` + 默认模型 + effort | 运行、认证判定 | RPC probe | 模型目录 | PiRpcClient | 无 | +| `list/pi/pi-auth` | 安装(`--version`)+ 认证(RPC probe 得模型) | 凭据管理 UI | 配置 | 状态 | PiPaths、PiRpcClient | 无 | +| `list/pi/pi-skills` | `get_commands` 过滤 skill,`/skill:` | 安装/删除路径约束(复用现有 skills 规则) | RPC | skill 列表 | PiRpcClient、PiPaths | 无 | +| `list/pi/pi-token-usage`(经 pi-sessions 快照) | 从快照取最后有效 usage | JSONL 二次读取 | app session | usage | PiSessionStore | 无 | +| `list/pi/pi-mcp`(unsupported adapter) | 读返回空分组、写抛不支持 | 真实 MCP 配置 | - | 空 / 错误 | 无 | 无 | + +- [x] 没有任何模块跨越多个领域。 +- [x] 每一行的「不负责」都已填写。 +- [x] 每份状态数据都只有一个归属模块(session 行由 synchronizer 归属;与其他 provider 共享同一张表但按 provider 维度写入)。 +- [x] 依赖单向:facet → PiSessionStore/PiRpcClient → PiPaths → fs/官方包,无环。 + +## 规则与约束 + +| 类型 | 规则 | 覆盖需求 | +|---|---|---| +| 业务规则 | Pi 仅暴露 `plan` 与 `bypassPermissions` 两个权限模式,默认 `bypassPermissions` | Pi 权限模式 | +| 业务规则 | skill 调用格式为 `/skill:`(不照抄 OpenCode 的 `/`) | Pi skills 发现 | +| 业务规则 | token usage 取 active branch 最后一个无 error/未中止/字段完整的 assistant usage | Pi token usage | +| 系统规则 | runtime 与 auth/model probe 使用相同 flags(含 `--no-extensions`),保证目录与运行一致 | runtime / 模型 / 认证 | +| 系统规则 | 第一条 live 事件之前必须完成并持久化 app/native 绑定 | session 身份绑定 | +| 系统规则 | `agent_settled` 是唯一成功终态;其之前的进程关闭视为失败 | live chat runtime | +| 系统规则 | 进程归属按 runId 管理,abort 只作用当前 run | 运行中止 | +| 技术约束 | RPC 为严格 JSONL、仅 `\n` 分隔;由官方客户端处理,禁止用 Node `readline` 自行分帧 | live chat runtime | +| 技术约束 | 依赖锁定为精确稳定版本(无 `^`/`~`/`latest`) | 全部 | +| 技术约束 | 所有新增 backend 文件为 TypeScript | 全部 | + +## 错误码注册表 + +| ERR ID | 常量名 | 错误码 | 提示文案 | 引用位置 | +|---|---|---|---|---| +| ERR-UNSUPPORTED-PROVIDER | `UNSUPPORTED_PROVIDER` | 已有 | 「不支持的 provider」(沿用现有 registry 错误) | registry(现存) | +| ERR-PROVIDER-CAPABILITY-UNSUPPORTED | `PROVIDER_CAPABILITY_UNSUPPORTED` | 400 | 「该 provider 不支持此能力」 | pi-mcp 写操作 | +| ERR-PI-NOT-INSTALLED | `PI_NOT_INSTALLED` | 状态值 | 「Pi 未安装」(正常状态,非抛异常) | pi-auth | +| ERR-PI-NOT-AUTHENTICATED | `PI_NOT_AUTHENTICATED` | 状态值 | 「Pi 未认证」(正常状态) | pi-auth / pi-models | +| ERR-PI-RPC-PROTOCOL | `PI_RPC_PROTOCOL` | 502 | 「Pi RPC 协议错误」 | pi-runtime 事件映射 | +| ERR-PI-RUN-FAILED | `PI_RUN_FAILED` | 500 | 「Pi 运行失败」 | pi-runtime 终态 | +| ERR-PI-SESSION-CORRUPT | `PI_SESSION_CORRUPT` | 500 | 「Pi session 文件损坏(行号 N)」 | PiSessionStore | +| ERR-PI-SESSION-VERSION-UNSUPPORTED | `PI_SESSION_VERSION_UNSUPPORTED` | 500 | 「不支持的 Pi session 版本」 | PiSessionStore | + +## 数据模型 + +不新增数据库字段、不新增迁移。Pi 复用现有 `sessions` 表与 `assignProviderSessionId`/`getSessionByProviderSessionId`(E9,provider-less)。 + +| 字段 | 类型 | 必填 | 含义 | 示例 | 约束 | 枚举值 | 默认值 | 空值语义 | +|---|---|---|---|---|---|---|---|---| +| `LLMProvider` | union 扩展 | 是 | 新增成员 `'pi'` | `'pi'` | 类型联合 | claude/codex/cursor/opencode/**pi** | - | - | + +Pi 运行状态机(进程内,非持久化): +**状态流转:** `IDLE -> SPAWNING -> REQUESTING_STATE -> BINDING_SESSION -> PROMPTING -> STREAMING -> SETTLED -> CLOSED`;任一非终态可 `-> ABORTING -> ABORTED` 或 `-> FAILED`。终态:`CLOSED`、`ABORTED`、`FAILED`。 + +## 非功能要求 + +| 维度 | 要求 | +|---|---| +| 延迟 / 吞吐 | 一次 turn 一个 RPC 进程;首个流式事件应在 spawn+get_state 后立即产出,不额外缓冲 | +| 并发 | 一个 app session 同时最多一个 active run(沿用 `ChatRunRegistry`) | +| 超时 | auth/model probe 有上限超时(建议 ≤10s);abort 优雅窗口有上限(建议 ≤5s)后强杀 | +| 重试策略 | Pi 内部 retry 映射为 `status` 事件;本层不对 RPC 做自动重试 | +| 一致性 | app/native 绑定在第一条 live 事件前持久化;同一份 session JSONL 只解析一次(PiSessionStore 快照) | +| 可观测性 | unknown event 记 debug;协议错误、spawn 错误、session 损坏各有独立错误码 | + +## 风险与权衡 + +- [跨 provider native session id 碰撞] -> 不做唯一约束/provider-qualified lookup(切给重构)。Pi native id 为 UUID,碰撞概率可忽略;作为已知局限记录,重构变更中根治。(E9) +- [全局 scan cursor:Pi 同步失败使本轮游标不推进,下轮重扫] -> 各 synchronizer 独立执行,不丢 upsert,仅产生一次冗余重扫;不阻塞其他 provider 数据写入。(E7) +- [依赖 Pi 内部 `rpc-client` 稳定性] -> 锁定精确稳定版本;升级 Pi 需回归 RPC fixture 测试。(E11/E12) +- [`agent_settled` 是新特性,版本敏感] -> 锁定包含该特性的稳定版本并 `[PENDING_VERIFY]` 实测;probe/runtime 统一 `--no-extensions` 避免交互挂起。(E12) +- [token-usage 中央默认落 claude] -> Pi 显式在 token-usage service 增加 `pi` 分支,命中前不进入默认路径。(E5) + +## 迁移计划 + +无数据库迁移。上线为纯新增:加依赖 → 加 `list/pi/` → registry 与中央分支注册 → 前端。回滚 = 移除 registry 注册与中央 Pi 分支(现有 4 provider 不受影响)。无已写入数据需处理。 + +## 待明确问题 + +- Pi 锁定的具体稳定版本号(须包含 `agent_settled` 与 RPC)——在 tasks 第一步 `[PENDING_VERIFY]` 中确定,不改变 spec 或模块边界。 +- `--tools` 只读子集在锁定版本中的确切工具名(`plan` 模式用)——实测确定,属实现细节。 diff --git a/openspec/changes/add-pi-provider/proposal.md b/openspec/changes/add-pi-provider/proposal.md new file mode 100644 index 0000000000..a8a91427dc --- /dev/null +++ b/openspec/changes/add-pi-provider/proposal.md @@ -0,0 +1,31 @@ +## Why + +工程当前支持 4 个 provider(Claude、Codex、Cursor、OpenCode),需要接入第 5 个 Pi coding agent(`@earendil-works/pi-coding-agent`)。Pi 提供官方 RPC 模式和官方客户端实现,可以直接复用,无需从零手写进程协议。 + +本次变更**只做 Pi 集成**,刻意不捆绑「provider 公共接缝的架构重构」——那是一个独立的、影响现有 4 个 provider 的高风险变更,与「新增一个 provider」是两件不同的事。Pi 沿用当前已经存在的 `IProvider` 契约与中央装配模式接入,把架构收口留给后续独立变更。 + +## What Changes + +- 新增 `pi` 到 `LLMProvider` 类型联合。 +- 新增 `server/modules/providers/list/pi/` 目录,实现 `IProvider` 的各 facet:runtime、models、auth、sessions、sessionSynchronizer、skills,以及一个「不支持」语义的 mcp facet(当前 `IProvider` 强制 mcp)。 +- 引入官方依赖 `@earendil-works/pi-coding-agent`,**锁定到一个已包含 RPC 模式与 `agent_settled` 的稳定发布版本**(非 `latest`/`^`),Pi runtime 复用官方 RPC 客户端而非自研 JSONL 协议。 +- 在 registry 注册 Pi,并按现有模式补齐必要的中央接入点(capabilities、token usage、watcher 路径、agent 路由、前端 provider/model/brand state)。 +- 前端新增 Pi 品牌元数据、logo、model picker、permission picker(仅 `plan` 与 `bypassPermissions`)、`/skill:` 展示。 +- 非目标:**不**重构 provider 公共接缝(capabilities 派生、optional facet、typed runtime、generic coordinator、per-provider scan cursor 等)。这些保留现状,留待独立变更。 + +## Capabilities + +### New Capabilities +- `pi-provider`: 把 Pi coding agent 作为一个完整 provider 接入,覆盖 live chat runtime、模型目录、安装/认证状态、session 历史与磁盘同步、skills 发现、权限模式,以及在 MCP 与 token usage 上的能力表达。 + +### Modified Capabilities + + +## Impact + +- 依赖:新增 `@earendil-works/pi-coding-agent`(锁定稳定版本)。 +- 类型:`server/shared/types.ts` 的 `LLMProvider` 联合。 +- Backend:`provider.registry.ts`、`provider-capabilities.service.ts`、`provider-token-usage.service.ts`、`sessions-watcher.service.ts`、`agent/agent.routes.ts` 各新增 Pi 分支(沿用现有模式)。 +- 新增:`server/modules/providers/list/pi/` 全部 facet 文件。 +- Frontend:provider/model/brand state 与相关 picker 组件新增 Pi。 +- 不影响现有 4 个 provider 的运行时行为。 diff --git a/openspec/changes/add-pi-provider/specs/pi-provider/spec.md b/openspec/changes/add-pi-provider/specs/pi-provider/spec.md new file mode 100644 index 0000000000..a638b3b242 --- /dev/null +++ b/openspec/changes/add-pi-provider/specs/pi-provider/spec.md @@ -0,0 +1,173 @@ +## Purpose + +把 Pi coding agent 作为一个完整 provider 接入本工程,使其与现有 4 个 provider 一样,通过统一的 provider 契约提供 live chat、模型目录、安装/认证状态、session 历史与磁盘同步、skills 发现与权限模式,并在不支持的能力上以明确的「不支持」而非空成功来表达。 + +## ADDED Requirements + +### Requirement: Pi provider 注册与能力表达 + +The system SHALL 将 `pi` 作为受支持的 provider 注册,使其可被 provider registry 解析,并向前端暴露其能力描述(权限模式、是否支持 effort、token usage、MCP、skills、abort、images、files)。 + +The system SHALL NOT 把「不支持某能力」表达为空成功结果;对 Pi 不支持的能力,调用方 SHALL 收到明确的不支持错误或能力描述中的 `false`。 + +#### Scenario: 解析 Pi provider +- **WHEN** 调用方以 `pi` 请求 provider registry +- **THEN** 系统返回 Pi provider 实例及其能力描述 + +#### Scenario: 未注册 provider +- **WHEN** 调用方以一个未注册的 provider id 请求 +- **THEN** 系统以 `ERR-UNSUPPORTED-PROVIDER` 拒绝,且不产生任何副作用 + +#### Scenario: 已知 provider 但能力不支持 +- **WHEN** 调用方对 Pi 请求其不支持的 MCP 能力 +- **THEN** 系统以 `ERR-PROVIDER-CAPABILITY-UNSUPPORTED` 拒绝,而不是返回空的成功结果 + +### Requirement: Pi live chat runtime + +The system SHALL 通过 Pi 官方 RPC 模式(`pi --mode rpc`)执行一次对话运行,将 Pi 的非终态事件(文本、思考、工具开始/结束、状态、错误)转换为应用归一化事件流式输出,并以 Pi 的 `agent_settled` 事件作为该次运行成功完成的唯一权威终态。 + +The system SHALL NOT 把 `agent_end`、`turn_end` 或进程 exit code 0 单独当作成功终态;在 `agent_settled` 之前发生的进程关闭 SHALL 产生失败结果。 + +#### Scenario: 正常流式对话 +- **WHEN** 用户在一个 Pi session 发送 prompt +- **THEN** 系统流式返回归一化的文本与思考事件,并在收到 `agent_settled` 后产生一次成功完成 + +#### Scenario: 进程在 settle 前关闭 +- **WHEN** Pi 进程在发出 `agent_settled` 之前退出 +- **THEN** 系统以 `ERR-PI-RUN-FAILED` 产生失败完成,且不谎报成功 + +#### Scenario: 已知事件携带非法 payload +- **WHEN** Pi 发来一个已知类型但 payload 非法的事件 +- **THEN** 系统以 `ERR-PI-RPC-PROTOCOL` 产生失败,而不是把它当作成功数据 + +#### Scenario: 未知事件 +- **WHEN** Pi 发来一个未知类型的事件 +- **THEN** 系统记录调试信息后忽略该事件,不影响本次运行 + +### Requirement: app/native session 身份绑定 + +The system SHALL 在 Pi runtime 产生第一条 live 事件之前,用 `get_state` 返回的精确 session id 与 session 文件完成 app session 与 provider-native session 的绑定并持久化。 + +The system SHALL NOT 依赖时间启发式(如「最近一个 pending session」)来推断 Pi 的 native session;Pi 已能提供确定性 session id。 + +#### Scenario: 首轮建立绑定 +- **WHEN** 一个新 Pi session 首次运行并成功获得 `get_state` +- **THEN** 系统在第一条 live 事件之前持久化 app/native session 映射 + +#### Scenario: 第二轮复用同一 app session +- **WHEN** 用户在同一 app session 发起第二轮对话 +- **THEN** 系统复用已持久化的映射,不创建重复 native 绑定 + +### Requirement: Pi 运行中止 + +The system SHALL 支持针对当前运行的中止:向 Pi RPC 发出中止指令,在有上限的优雅关闭窗口内等待 `agent_settled`,超时后终止该运行的进程,并产生一次「已中止」完成。 + +The system SHALL NOT 让中止误伤同一 session 的其他运行;进程归属 SHALL 按运行标识管理,而非仅按 session id。 + +#### Scenario: 正常中止 +- **WHEN** 用户中止一个正在流式输出的 Pi 运行 +- **THEN** 系统停止该运行并产生一次「已中止」完成 + +#### Scenario: 优雅窗口超时 +- **WHEN** Pi 在优雅关闭窗口内未响应中止 +- **THEN** 系统强制终止该运行的进程并仍产生一次「已中止」完成 + +### Requirement: Pi 模型目录 + +The system SHALL 通过 Pi RPC `get_available_models` 提供模型目录,默认模型取自 `get_state`,仅对具备推理能力的模型暴露 thinking effort,并使用 `/` 作为 canonical 模型值。 + +#### Scenario: 列出模型 +- **WHEN** 前端请求 Pi 的模型目录 +- **THEN** 系统返回 canonical 格式的可用模型列表与默认模型 + +#### Scenario: Pi 未认证时取模型 +- **WHEN** 在 Pi 未认证的情况下请求模型目录 +- **THEN** 系统以 `ERR-PI-NOT-AUTHENTICATED` 表达,而不是返回空目录冒充成功 + +### Requirement: Pi 安装与认证状态 + +The system SHALL 报告 Pi 的安装状态(配置的 Pi 可执行文件能否成功执行 `--version`)与认证状态(用与 runtime 相同配置启动 RPC probe 并成功获得至少一个当前可用模型)。 + +The system SHALL NOT 为「未安装」或「未认证」这类正常状态抛出异常;这些属于正常返回值,仅协议损坏、spawn 权限错误等异常与之区分。 + +#### Scenario: 已安装且已认证 +- **WHEN** 查询 Pi 状态且可执行文件可用、probe 返回至少一个模型 +- **THEN** 系统报告已安装且已认证 + +#### Scenario: 未安装 +- **WHEN** 配置的 Pi 可执行文件无法执行 `--version` +- **THEN** 系统报告未安装,且不抛异常 + +#### Scenario: 已安装但未认证 +- **WHEN** Pi 可执行但 probe 无法获得任何可用模型 +- **THEN** 系统报告已安装但未认证,且不抛异常 + +### Requirement: Pi session 历史读取 + +The system SHALL 从 Pi 的 session JSONL 文件解析历史:按行解析、只转换 active branch、并将其转换为归一化 message;系统对文件末尾的半行(并发写入)SHALL 忽略而非报错。 + +The system SHALL NOT 静默误读不支持的未来 session 版本,也 SHALL NOT 因中间行损坏而返回部分错误数据。 + +#### Scenario: 读取有效历史 +- **WHEN** 请求一个有效 Pi session 的历史 +- **THEN** 系统返回 active branch 上的归一化 message 序列 + +#### Scenario: 尾部半行 +- **WHEN** session 文件末尾存在一个未写完的半行 +- **THEN** 系统忽略该半行并正常返回其余历史 + +#### Scenario: 中间行损坏 +- **WHEN** session 文件中间存在损坏行 +- **THEN** 系统以 `ERR-PI-SESSION-CORRUPT` 报错并包含行号,不返回部分数据 + +#### Scenario: 不支持的 session 版本 +- **WHEN** session 文件头声明一个不受支持的版本 +- **THEN** 系统以 `ERR-PI-SESSION-VERSION-UNSUPPORTED` 报错,而不是按旧格式误读 + +### Requirement: Pi session 磁盘同步 + +The system SHALL 扫描 Pi 的 session 根目录,将发现的 session metadata(provider=`pi`、native session id、项目路径、artifact 路径、当前模型、创建/更新时间)upsert 到数据库;各 provider 的同步 SHALL 独立执行,Pi 同步失败不中断其他 provider 在同一次扫描中的 upsert。 + +#### Scenario: 同步发现新 session +- **WHEN** Pi session 根目录出现一个新 session 文件 +- **THEN** 系统 upsert 其 metadata,且下游以 app session id 呈现 + +#### Scenario: Pi 同步失败不中断其他 provider +- **WHEN** Pi 同步过程抛错 +- **THEN** 系统仍完成其他 provider 在本次扫描中发现的 session upsert,仅记录 Pi 失败 + +### Requirement: Pi skills 发现 + +The system SHALL 通过 Pi RPC `get_commands` 并过滤 `source === 'skill'` 发现 skills,且以 `/skill:` 作为其调用格式展示。 + +#### Scenario: 列出 Pi skills +- **WHEN** 请求 Pi 的 skills 列表 +- **THEN** 系统返回过滤后的 skill,并以 `/skill:` 格式呈现调用语法 + +### Requirement: Pi 权限模式 + +The system SHALL 仅为 Pi 暴露两个行为不同的权限模式:`plan`(只读工具子集)与 `bypassPermissions`(Pi 默认完整工具集),默认 `bypassPermissions`,并声明不支持逐工具确认请求。 + +The system SHALL NOT 为 Pi 暴露 `default`/`acceptEdits` 等在 Pi 上行为无差异的模式,也 SHALL NOT 把工具 allowlist 描述为操作系统级安全隔离。 + +#### Scenario: 暴露 Pi 权限模式 +- **WHEN** 前端读取 Pi 的能力描述 +- **THEN** 系统仅返回 `plan` 与 `bypassPermissions` 两个模式,默认 `bypassPermissions` + +#### Scenario: 切换到 Pi 时权限模式不兼容 +- **WHEN** 用户从其他 provider 切到 Pi,而当前模式不在 Pi 的模式列表中 +- **THEN** 前端回退到 Pi 的默认模式 + +### Requirement: Pi token usage + +The system SHALL 从 Pi session 快照中取 active branch 上最后一个满足「无 error 停止原因、未中止、usage 字段完整」的 assistant usage 作为该 session 的 token usage。 + +The system SHALL NOT 在 Pi 缺少可用 usage 时回退到其他 provider 的默认统计口径。 + +#### Scenario: 读取 Pi usage +- **WHEN** 请求一个含有效 usage 的 Pi session 的 token usage +- **THEN** 系统返回 active branch 上最后一个有效 assistant usage + +#### Scenario: 无有效 usage +- **WHEN** Pi session 不存在满足条件的 usage +- **THEN** 系统返回「无 usage」而非套用其他 provider 的默认值 diff --git a/openspec/changes/add-pi-provider/tasks.md b/openspec/changes/add-pi-provider/tasks.md new file mode 100644 index 0000000000..cfb6036677 --- /dev/null +++ b/openspec/changes/add-pi-provider/tasks.md @@ -0,0 +1,61 @@ +# Pi Provider 实现任务 + +「构建什么」见 `specs/pi-provider/spec.md`,「怎么构建」见 `design.md`。design 的两个待明确问题(Pi 版本号、`plan` 模式 `--tools` 工具名)不改变 spec 与模块边界,在任务 1.1 / 3.x 中以实测确定。 + +## 0. 文件归属 + +| 任务组 | 独占文件/目录 | 禁止改动 | 共享文件处理 | +|---|---|---|---| +| 1 | `package.json`、`server/shared/types.ts` | `server/modules/providers/**` | 无 | +| 2 | `server/modules/providers/list/pi/pi-paths.provider.ts`、`pi-rpc-client.provider.ts`、`pi-session-store.provider.ts` 及其测试 | 中央 service、其他 provider | 无 | +| 3 | `server/modules/providers/list/pi/`(除任务组 2 的三个文件外的全部 facet 与 `pi.provider.ts`、`index.ts`)及其测试 | 中央 service、任务组 2 文件 | 依赖任务组 2 产物,串行在其后 | +| 4 | `provider.registry.ts`、`provider-capabilities.service.ts`、`provider-token-usage.service.ts`、`sessions-watcher.service.ts`、`agent/agent.routes.ts` | `list/pi/`、前端 | 依赖任务组 3,串行在其后 | +| 5 | `src/`(前端 provider/model/brand state 与 picker 组件) | backend | 无 | +| 6 | 无(只运行验证命令) | 全部 | 只读 | + +- [x] 任意两个任务组的「独占文件」无交集。 +- [x] 共享文件已指定串行顺序(3 依赖 2,4 依赖 3)。 + +## 1. 前置:版本锁定与类型 + +- [ ] 1.1 确定并锁定 Pi 稳定版本:核实包含 RPC 模式与 `agent_settled` 的稳定发布版本号,在 `package.json` 以**精确版本**(无 `^`/`~`)添加 `@earendil-works/pi-coding-agent`,`npm install` 后确认可 import 官方 rpc-client。 +- [ ] 1.2 在 `server/shared/types.ts` 的 `LLMProvider` 联合中加入 `'pi'`,运行 typecheck 定位所有因新增成员而需要补分支的中央位置(作为任务组 4 的清单)。 + +## 2. Pi 基础模块 + +- [ ] 2.1 实现 `PiPaths`:解析 `PI_CLI_PATH`、`PI_CODING_AGENT_DIR`、`PI_CODING_AGENT_SESSION_DIR`、`settings.json.sessionDir`、默认 `/sessions`,暴露 `getSessionRoots()`;所有路径 resolve/normalize。补单测覆盖各配置来源。 +- [ ] 2.2 实现 `PiRpcClient`:薄封装官方 rpc-client,spawn `pi --mode rpc --no-extensions`,提供请求/响应关联、事件分发、stderr 收集、process close 时 reject 全部 pending、graceful close。补单测:单 chunk 多行、一行跨多 chunk、尾部无换行、畸形 JSON、stderr 不污染 parser、unexpected close reject pending(对应 T18/T19/T20)。 +- [ ] 2.3 实现 `PiSessionStore.load()` 返回不可变 `PiSessionSnapshot`:JSONL 按行解析、尾部半行忽略、中间损坏报 `ERR-PI-SESSION-CORRUPT`(含行号)、不支持版本报 `ERR-PI-SESSION-VERSION-UNSUPPORTED`、active branch 回溯(检测 cycle/重复 id/丢失 parent)、compaction/branch summary、最后 `model_change`、最后有效 usage。补单测覆盖 T14–T17、T21、T22、T23。 + +## 3. Pi facets 与装配 + +- [ ] 3.1 实现 `pi-runtime.provider.ts`(TypeScript):状态机 `SPAWNING→REQUESTING_STATE→BINDING_SESSION→PROMPTING→STREAMING→SETTLED`;在首条 live 事件前完成 session 绑定;事件映射为 pure function(text_delta/thinking_delta/tool_execution_*/turn_end/retry→status);`agent_settled` 为唯一成功终态,之前 close 返回 `ERR-PI-RUN-FAILED`;已知事件非法 payload 返回 `ERR-PI-RPC-PROTOCOL`,未知事件忽略。补单测 T1–T5、T8、T28(用真实 RPC fixture)。 +- [ ] 3.2 实现 abort:`request.signal` 触发写 `{type:'abort'}`,有上限优雅窗口后强杀,进程按 runId 归属,返回「已中止」outcome。补单测 T7、T9,并验证 abort 与 late event 竞争仅一个终态(T8)。 +- [ ] 3.3 实现 `pi-models.provider.ts`:`get_available_models` + `get_state.model` 默认 + 仅 reasoning 模型暴露 effort + canonical `/`;未认证返回 `ERR-PI-NOT-AUTHENTICATED`。补单测 T10、T11。 +- [ ] 3.4 实现 `pi-auth.provider.ts`:安装=`--version` 成功;认证=RPC probe(同 runtime flags,含 `--no-extensions`)获得≥1 模型;未安装/未认证为正常返回不抛异常。补单测 T12、T13,并断言 probe 与 runtime flags 一致(T28)。 +- [ ] 3.5 实现 `pi-sessions.provider.ts`:消费 `PiSessionStore` 快照,`normalizeMessage`/`fetchHistory`,稳定 message id `:`,沿用现有 `sliceTailPage` 分页。补单测 T14、T15、T21。 +- [ ] 3.6 实现 `pi-session-synchronizer.provider.ts`:从 `PiPaths.getSessionRoots()` 扫描,快照 upsert(provider=`pi`、native id、cwd、artifact、模型、时间)。补单测:发现新 session、Pi 抛错不中断其他 provider upsert。 +- [ ] 3.7 实现 `pi-token-usage`(经快照)与 `pi-skills.provider.ts`(`get_commands` 过滤 `source==='skill'`,`/skill:`,写 root=`agentDir/skills`,复用现有名称/路径校验)。补单测 T22、T23、T24 及路径穿越拒绝。 +- [ ] 3.8 实现 `pi-mcp.provider.ts`(unsupported adapter):读返回分组空结构,写抛 `ERR-PROVIDER-CAPABILITY-UNSUPPORTED`。补单测 T26。 +- [ ] 3.9 组装 `pi.provider.ts`(继承 `AbstractProvider`,注入全部 facet)与 `index.ts`,仅导出注册所需公共定义。 + +## 4. 中央注册(沿用现有模式,逐点接入) + +- [ ] 4.1 `provider.registry.ts`:`providers` Record 加入 `pi: new PiProvider()`;补测未注册 provider 返回 `ERR-UNSUPPORTED-PROVIDER`(T27)、`pi` 可解析。 +- [ ] 4.2 `provider-capabilities.service.ts`:新增 `pi` 条目——`permissionModes:['plan','bypassPermissions']`、`defaultPermissionMode:'bypassPermissions'`、`supportsPermissionRequests:false`、`supportsMcp:false`、`supportsEffort`(按 reasoning 模型)、images/files/abort/tokenUsage 据实。补测能力描述与 facet 存在性一致(T25)。 +- [ ] 4.3 `provider-token-usage.service.ts`:新增 `pi` 分支,走 Pi usage facet,命中前不落 `.claude` 默认。 +- [ ] 4.4 `sessions-watcher.service.ts`:`PROVIDER_WATCH_PATHS` 加入 Pi 的 `getSessionRoots()`。 +- [ ] 4.5 `agent/agent.routes.ts`:注入 `queryPi` 并加入 provider 分支(沿用现有 `if/else` 结构,不消除 `@ts-nocheck`)。 + +## 5. 前端 + +- [ ] 5.1 新增 `pi` provider type、brand metadata 与 logo。 +- [ ] 5.2 model state 加入 Pi(沿用现有每-provider state 模式,不重构为 Record);model picker 展示 canonical Pi 模型,仅 reasoning 模型展示 effort。 +- [ ] 5.3 permission picker 对 Pi 仅展示 `plan`/`bypassPermissions`;切到 Pi 时若当前 mode 不兼容则回退默认;MCP 页面不显示 Pi 为可配置 provider;skills 展示 `/skill:`。 + +## 6. 验证 + +- [ ] 6.1 运行窄测试:`node --import tsx --test` 覆盖任务组 2/3/4 的新测试文件,全绿。 +- [ ] 6.2 运行 `npm run build`、`npm run typecheck`、`npm run lint`、`npm test`,全绿。 +- [ ] 6.3 真实 Pi smoke:RPC 安装/模型 probe、新建 session 流式 text/thinking、tool start/end、第二轮 resume、abort、进程完成后立即读 history、filesystem sync 后 sidebar 用 app session id——逐项通过。 +- [ ] 6.4 回归确认现有 4 个 provider 冒烟不受影响。 diff --git a/openspec/changes/add-pi-provider/test-definition.md b/openspec/changes/add-pi-provider/test-definition.md new file mode 100644 index 0000000000..1339b735dc --- /dev/null +++ b/openspec/changes/add-pi-provider/test-definition.md @@ -0,0 +1,118 @@ +# Pi Provider 测试定义 + +依据 `specs/pi-provider/spec.md` 的需求与场景。本变更涉及外部进程、RPC 协议解析、并发中止竞态、磁盘 session 解析,属真实风险变更,需在场景通过/失败之外给出分维度门槛。 + +## 测试目标与边界 + +**范围内:** +- Pi runtime 的事件映射、终态判定(`agent_settled` 唯一成功终态)、协议错误处理。 +- app/native session 绑定发生在第一条 live 事件之前。 +- abort 只作用当前 run、优雅窗口超时后强杀。 +- PiSessionStore 的 JSONL 解析:有效、尾部半行、中间损坏、不支持版本、active branch、usage 提取。 +- models/auth 的 RPC probe 行为与「未安装/未认证」正常态。 +- skills `/skill:` 发现;权限模式仅两种;MCP 不支持表达。 +- registry 解析与能力描述一致性。 + +**范围外:** +- provider 公共接缝重构(唯一约束、per-provider cursor、typed runtime)——不在本变更。 +- 现有 4 个 provider 的行为回归(本变更不改其运行时代码,仅新增中央 Pi 分支)。 +- Pi 上游模型本身的输出质量。 + +## 覆盖策略 + +| 维度 | 是否覆盖 | 样本数 | 说明 | +|---|---|---|---| +| 正常路径 | 是 | 9 | 每条 spec 需求的正常场景各一 | +| 异常 | 是 | 8 | 进程早关、协议错误、未认证、未安装、中间损坏、不支持版本、优雅超时、无 usage | +| 边界 | 是 | 5 | 单 chunk 多行、一行跨多 chunk、尾部半行、active leaf 回溯、最后 model_change | +| 对抗 | 是 | 4 | 畸形 JSON、stderr 污染 stdout parser、未知事件、已知事件非法 payload | +| 高风险 | 是 | 4 | abort 与 late native event 竞争仅一个终态、绑定先于首事件、进程 unexpected close reject pending、`--no-extensions` probe/runtime 一致 | + +## 评测集 + +RPC 与 session JSONL 使用**去敏的真实 Pi fixture**(移除凭据与用户路径)。样本按 spec 场景一一映射挑选,覆盖每类适用异常。 + +| 编号 | 输入 | 预期 | 维度 | 来源 | +|---|---|---|---|---| +| T1 | 正常 prompt,RPC 流至 `agent_settled` | 归一化 text/thinking 流 + 一次成功完成 | 正常 | spec: live chat | +| T2 | 进程在 `agent_settled` 前 close | `ERR-PI-RUN-FAILED` 失败完成 | 异常 | spec: live chat | +| T3 | 已知事件携带非法 payload | `ERR-PI-RPC-PROTOCOL` | 对抗 | spec: live chat | +| T4 | 未知事件 | 忽略并记 debug,运行不受影响 | 对抗 | spec: live chat | +| T5 | 新 session 首轮 get_state 成功 | 首事件前持久化 app/native 绑定 | 高风险 | spec: 身份绑定 | +| T6 | 同 app session 第二轮 | 复用映射,无重复绑定 | 正常 | spec: 身份绑定 | +| T7 | abort 流式中的 run | 一次「已中止」完成 | 正常 | spec: 中止 | +| T8 | abort 后 Pi 迟发 native event | 仍只有一个终态 | 高风险 | spec: 中止 | +| T9 | 优雅窗口内无响应 | 强杀 + 「已中止」完成 | 异常 | spec: 中止 | +| T10 | `get_available_models` probe | canonical 模型列表 + 默认 | 正常 | spec: 模型 | +| T11 | 未认证下取模型 | `ERR-PI-NOT-AUTHENTICATED`,非空目录冒充 | 异常 | spec: 模型/认证 | +| T12 | 可执行 `--version` 失败 | 报未安装,不抛异常 | 异常 | spec: 认证 | +| T13 | 可执行但 probe 无模型 | 报未认证,不抛异常 | 异常 | spec: 认证 | +| T14 | 有效 v-header session | active branch 归一化 history | 正常 | spec: 历史 | +| T15 | 尾部半行 | 忽略半行,返回其余 | 边界 | spec: 历史 | +| T16 | 中间损坏行 | `ERR-PI-SESSION-CORRUPT` 含行号 | 异常 | spec: 历史 | +| T17 | 不支持 session 版本 | `ERR-PI-SESSION-VERSION-UNSUPPORTED` | 异常 | spec: 历史 | +| T18 | 单 chunk 含多行 / 一行跨多 chunk | 官方 client 正确分帧 | 边界 | spec: runtime(RPC 协议) | +| T19 | 畸形 JSON / stderr 混入 stdout | 不污染 parser,pending 正确处理 | 对抗 | spec: runtime | +| T20 | 进程 unexpected close | reject 所有 pending request | 高风险 | spec: runtime | +| T21 | active branch 最后 model_change | 取该模型为当前模型 | 边界 | spec: 模型/历史 | +| T22 | 最后一个非 error/未中止 usage | 返回该 usage | 正常 | spec: usage | +| T23 | 无满足条件 usage | 返回「无 usage」,不套用他 provider 默认 | 异常 | spec: usage | +| T24 | `get_commands` 过滤 skill | `/skill:` 格式 | 正常 | spec: skills | +| T25 | 读 Pi 能力描述 | 仅 `plan`/`bypassPermissions`,默认后者 | 正常 | spec: 权限 | +| T26 | 对 Pi 请求 MCP 写 | `ERR-PROVIDER-CAPABILITY-UNSUPPORTED` | 异常 | spec: 能力表达 | +| T27 | 未注册 provider id | `ERR-UNSUPPORTED-PROVIDER` | 异常 | spec: 注册 | +| T28 | probe 与 runtime flags 对比 | 两者均含 `--no-extensions`,一致 | 高风险 | design 规则 | + +## 评分规则 + +每个样本二值判定:断言全部满足=通过,否则失败。逐样本记录(输入摘要、实际输出/错误码、判定、失败归因)必须留存于测试输出。总分 = 通过数 / 总数,但放行看**分维度**门槛而非总分。真实 Pi smoke 另行逐项签署。 + +## 验收规则与回归门槛 + +| 规则 | 门槛 | 适用范围 | +|---|---|---| +| 正常路径全通过 | 100% | T1,T6,T7,T10,T14,T22,T24,T25 | +| 异常路径 | 100% | 全部异常样本(错误码精确匹配) | +| 高风险全通过 | 100% | T5,T8,T20,T28 | +| 对抗 | 100% | T3,T4,T19 | +| 边界 | ≥ 90% | T15,T18,T21 等 | + +## 上线门禁 + +- [ ] narrow 测试、`npm run build`、`npm run typecheck`、`npm run lint`、`npm test` 全绿。 +- [ ] 所有异常样本错误码精确匹配(不得以空成功冒充)。 +- [ ] 高风险样本(终态唯一性、绑定时序、pending reject、flags 一致)全通过。 +- [ ] 真实 Pi smoke:新建 session 流式、第二轮 resume、abort、进程完成后立即读 history、sync 后 sidebar 用 app session id——逐项通过。 +- [ ] 锁定的 Pi 版本号已确定并写入 `package.json`(精确版本),`agent_settled` 已实测存在。 +- [ ] 现有 4 个 provider 冒烟不回归。 + +## 报告审核清单 + +| 审核项 | 必须确认 | 不通过情形 | 结论影响 | +|---|---|---|---| +| 场景覆盖 | 五个维度均覆盖 | 只跑正常路径 | 不得放行 | +| 样本结构 | fixture 为去敏真实数据,覆盖真实风险 | 用手造理想数据替代真实 RPC/JSONL | 报告不可信 | +| 评分规则 | 逐样本记录含错误码与归因 | 只有总分 | 结论不可审计 | +| 验收规则 | 各维度达门槛 | 高风险样本失败 | 不能判定通过 | +| 问题归因 | 每个失败归因到 spec/设计/编排/模型/数据 | 只写「模型问题」 | 无法闭环 | +| 回归结果 | 修复后重跑并附证据 | 只有修复说明 | 问题不得关闭 | +| 门禁结论 | 全部硬门禁满足 | 任一未达标 | 直接不放行 | + +**结论:** 待执行 — 依上述门禁判定。 + +## E2E 验证配置(可选) + +| 字段 | 值 | +|---|---| +| VisionE2E 项目 | (按需,前端 Pi provider 可视化) | +| 目标 URL | 本地 dev server | +| 用例来源 | 生成 | +| 生成策略 | smoke | +| 登录方式 | none | + +### E2E 验收门槛 + +| 维度 | 通过率门槛 | 说明 | +|---|---|---| +| 正常路径 | ≥ 100% | Pi 选择 provider → 新建 session → 发送 → 流式显示 | +| 异常 | ≥ 80% | 未认证提示、切 provider 权限模式回退 | diff --git a/openspec/changes/refactor-provider-seams/.openspec.yaml b/openspec/changes/refactor-provider-seams/.openspec.yaml new file mode 100644 index 0000000000..e08b5f89a2 --- /dev/null +++ b/openspec/changes/refactor-provider-seams/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-03 diff --git a/openspec/changes/refactor-provider-seams/design.md b/openspec/changes/refactor-provider-seams/design.md new file mode 100644 index 0000000000..710bbb157d --- /dev/null +++ b/openspec/changes/refactor-provider-seams/design.md @@ -0,0 +1,140 @@ +# Provider 公共接缝重构设计 + +## 背景 + +动机见 proposal.md - Why。本 change 是 `add-pi-provider` 切出的另一半。关键约束:**Pi 先落地并沿用现状**,本重构在其后进行,范围包含把已存在的 5 个 provider 都迁进新接缝。两者中央文件交集大,必须串行。 + +重构采用 Feathers「先加接缝、按可观测契约测试、再替换」的路线:先补齐 characterization tests(见 test-definition.md),再逐层替换,避免大范围一次性重写引入回归。 + +## 证据登记 + +| 编号 | 标签 | 陈述 | 依据 | 风险 | +|---|---|---|---|---| +| E1 | `[CONFIRMED]` | registry 仅是 `Record` + `resolveProvider`,无 descriptor/facet 概念 | `provider.registry.ts` | 低 | +| E2 | `[CONFIRMED]` | `IProvider` 的 `mcp`/`skills` 为必选 readonly,非 optional | `server/shared/interfaces.ts:47-56` | 中 | +| E3 | `[CONFIRMED]` | capability 为中央手写静态矩阵 | `provider-capabilities.service.ts:37+` | 低 | +| E4 | `[CONFIRMED]` | token-usage 逐 provider `if`,未命中落 `.claude` | `provider-token-usage.service.ts:269/281/293/326` | 中 | +| E5 | `[CONFIRMED]` | watcher 路径为中央常量 | `sessions-watcher.service.ts:15` | 低 | +| E6 | `[CONFIRMED]` | 同步失败则跳过全局游标推进;各 synchronizer 独立执行 | `session-synchronizer.service.ts:49` | 中 | +| E7 | `[CONFIRMED]` | native lookup 签名不含 provider | `sessions.db.ts:179,263` | 高 | +| E8 | `[CONFIRMED]` | agent 路由逐 provider `queryX` + `if/else`,`@ts-nocheck` | `agent/agent.routes.ts:1,21-24,985-1018` | 中 | +| E9 | `[CONFIRMED]` | `IProviderRuntime.run(command, options:AnyRecord, writer, context)`,`abort(sessionId)` | `server/shared/interfaces.ts:30` | 中 | +| E10 | `[CONFIRMED]` | runtime 为 `.js`,其余 facet 为 `.ts` | `list/*/*-runtime.provider.js` | 低 | +| E11 | `[CONFIRMED]` | 已有 provider-native mapping 测试与 merge 行为存在 | `database/tests/sessions-provider-mapping.test.ts` | 中 | +| E12 | `[PENDING_VERIFY]` | 现网 `sessions` 表是否已存在跨 provider 相同 native id 的碰撞行 | 迁移前须对真实库查询确认 | 高 | +| E13 | `[PENDING_VERIFY]` | `ChatRunRegistry`/`ChatSessionWriter` 当前终态去重与 seq 分配的确切位置 | `websocket/services/*`,迁移前读确认 | 中 | + +- [x] 每条现存代码陈述已登记。 +- [x] 无 INFERRED 项混入实施结论。 +- [x] 高风险项 E7 为 CONFIRMED;E12 标 PENDING_VERIFY,迁移前必须核实真实数据(涉及数据合并,硬规则要求)。 + +## 目标 / 非目标 + +**目标:** +- 把 8 处泄漏接缝收回 provider 模块,使"加 provider"回到"1 处注册 + 自身目录"。 +- 对外可观测行为在重构前后保持一致(本 spec 的行为契约除外,它们是刻意的行为收紧)。 +- 5 个现有 provider(含 Pi)全部迁入新接缝。 + +**非目标:** +- 不改变任何 provider 的上游 CLI 行为或模型能力。 +- 不新增产品功能(分支管理、session fork、extension UI 等)。 +- 不把重构扩大到与 provider 无关的 Git workflow。 + +## 设计决策 + +**决策 1:registry 升级为 descriptor + facet 真相,capability 从 facet 派生。** +替代方案:保留静态矩阵并加校验——被否,双真相必然漂移(E3)。 + +**决策 2:mcp/skills/usage 改 optional facet;unknown provider 与 unsupported facet 用不同错误码。** +替代方案:保留必选 facet + 空实现(如 Pi 的 unsupported mcp adapter)——被否,空成功掩盖"不支持",违反契约(E2)。迁移期可临时保留空实现 adapter,optional 化完成后删除。 + +**决策 3:引入 typed runtime + `ProviderRunCoordinator`(终态唯一所有者),现有 5 个 runtime 经 `LegacyProviderRuntimeAdapter` 接入,不一次性重写。** +替代方案:直接重写 5 个 runtime——被否,回归风险过大。适配器把 typed request↔旧 options、旧 writer event↔typed sink、`abort(sessionId)`↔`AbortSignal` 互转,并拦截旧 runtime 的 `complete/session_created`(E9/E10)。 + +**决策 4:native 身份加 `(provider, provider_session_id)` 唯一约束 + provider-qualified lookup/merge。** +替代方案:仅靠 UUID 唯一性(即 add-pi-provider 现状)——被否,那是临时兜底;本 change 的职责就是根治(E7)。迁移前必须先合并真实库中的重复行(E12)。 + +**决策 5:scan cursor 迁 per-provider 表;watcher roots 由 synchronizer 提供;session 通知走 application publisher port。** +替代方案:保留全局游标——被否,一个 provider 失败拖累全体重扫(E6)。 + +## 模块边界 + +| 模块 | 职责 | **不负责** | 输入 | 输出 | 依赖 | 状态归属 | +|---|---|---|---|---|---|---| +| `ProviderRegistry`(重塑) | descriptor/facet 真相、`requireFacet`、descriptor 校验、生成 capability response | 运行、DB、transport | provider 定义 | provider/能力 | provider 定义 | 无 | +| `ProviderRunCoordinator`(新) | 校验/身份/生命周期/唯一终态/replay 保留 | 事件语义映射、native 协议 | run 请求 | 归一化事件/终态 | registry、runtime、run registry | active run + 终态 | +| `LegacyProviderRuntimeAdapter`(新) | typed↔旧 runtime 互转 | 终态生产(交回 coordinator) | typed request | typed outcome | 各旧 runtime | 无 | +| session identity(重塑 `sessions.db`) | provider-qualified lookup/merge | 能力、runtime | `(provider, nativeId)` | session 行 | DB | `sessions` 表 | +| `provider_scan_state`(新表 + synchronizer service 改造) | per-provider 游标 | 能力、runtime | 同步结果 | 游标推进 | DB | `provider_scan_state` 表 | +| session change publisher port(新) | application→transport 通知端口 | provider 逻辑 | upsert 事件 | 通知 | WebSocket adapter(生产)/内存(测试) | 无 | +| agent application module(提取自 `agent.routes.ts`) | generic run 编排 | provider dispatch 分支 | HTTP 请求 | 响应 | coordinator | 无 | +| frontend model state(收敛) | `Partial>` 统一处理 | 逐 provider setter | provider/model | UI state | capability response | localStorage | + +- [x] 每模块单一领域。 +- [x] 「不负责」列已填。 +- [x] `sessions` 表由 session identity 归属;`provider_scan_state` 由 synchronizer 归属,无双写。 +- [x] 依赖单向:transport → application(coordinator/agent module) → registry → provider;providers 不再反向依赖 WebSocket(用 publisher port 打破原环 E-notify)。 + +## 规则与约束 + +| 类型 | 规则 | 覆盖需求 | +|---|---|---| +| 业务规则 | 一个 app session 同时最多一个 active run | 单一终态所有权 | +| 系统规则 | 终态只能由 coordinator 产生;runtime 只发非终态事件 | 单一终态所有权 | +| 系统规则 | 所有 native lookup/merge 必须携带 provider | 身份隔离 | +| 系统规则 | 每个 provider 独立推进自身游标 | 同步失败隔离 | +| 系统规则 | 能力描述从 facet 存在性派生,禁止第二份真相 | 能力表达一致 | +| 技术约束 | 现有 runtime 经 legacy adapter 接入,不一次性重写 | 全部(迁移安全) | +| 技术约束 | 唯一约束迁移前必须合并真实库重复行 | 身份隔离 | +| 技术约束 | 新增/改造 backend 文件为 TypeScript,逐步移除 `@ts-nocheck` | 全部 | + +## 错误码注册表 + +| ERR ID | 常量名 | 错误码 | 提示文案 | 引用位置 | +|---|---|---|---|---| +| ERR-UNSUPPORTED-PROVIDER | `UNSUPPORTED_PROVIDER` | 400 | 「不支持的 provider」 | registry.resolveProvider | +| ERR-PROVIDER-CAPABILITY-UNSUPPORTED | `PROVIDER_CAPABILITY_UNSUPPORTED` | 400 | 「该 provider 不支持此能力」 | registry.requireFacet | +| ERR-PROVIDER-DESCRIPTOR-INVALID | `PROVIDER_DESCRIPTOR_INVALID` | 500 | 「provider descriptor 非法」 | registry 注册校验 | + +## 数据模型 + +| 字段 | 类型 | 必填 | 含义 | 示例 | 约束 | 枚举值 | 默认值 | 空值语义 | +|---|---|---|---|---|---|---|---|---| +| `sessions` 唯一索引 | index | 是 | `(provider, provider_session_id)` 唯一 | - | `WHERE provider_session_id IS NOT NULL` | - | - | 部分索引:native id 为空的行不受约束 | +| `provider_scan_state.provider` | TEXT PK | 是 | 每 provider 一行游标 | `'pi'` | 主键 | 5 个 provider | - | 无行=该 provider 从未扫描 | +| `provider_scan_state.last_scanned_at` | TEXT | 是 | 该 provider 上次扫描时间 | ISO 字符串 | - | - | - | - | +| `IProvider.mcp` | optional facet | 否 | MCP 能力 | - | - | - | `undefined` | `undefined`=不支持 MCP | +| `IProvider.skills` | optional facet | 否 | skills 能力 | - | - | - | `undefined` | `undefined`=不支持 skills | +| `IProvider.usage` | optional facet | 否 | token usage 能力 | - | - | - | `undefined` | `undefined`=不支持 usage | + +**状态流转(一次 run,由 coordinator 拥有):** `REGISTERED -> RUNNING -> (COMPLETED | ABORTED | FAILED)`;后三者为终态,且对外恰好观察到一个。 + +## 非功能要求 + +| 维度 | 要求 | +|---|---| +| 延迟 / 吞吐 | 重构不得引入额外流式延迟;事件路径保持零额外缓冲 | +| 并发 | 一个 app session 同时最多一个 active run(沿用现有 run registry) | +| 一致性 | 唯一约束迁移在单事务内完成合并+建索引;provider-qualified merge | +| 可观测性 | 一次 run 恰好一个终态;unknown/unsupported 错误码可区分并被日志记录 | +| 兼容性 | 重构前后现有 4 provider 的 observable 行为由 characterization tests 锁定,零回归 | + +## 风险与权衡 + +- [唯一约束迁移在真实库遇到重复 native id] -> E12 迁移前查询并合并;provider-qualified merge SQL 含 `provider = ?`;单事务。此为不可逆数据操作,须 `[PENDING_VERIFY]` 核实后执行。 +- [大范围 runtime 迁移引入现有 provider 回归] -> 先补 characterization tests(test-definition),legacy adapter 分阶段替换。 +- [facet optional 化触及 `IProvider` 所有实现] -> BREAKING,但由类型系统兜底:改 interface 后 typecheck 会列出全部待改点。 +- [与 add-pi-provider 文件交集] -> 硬串行:本 change 必须在 add-pi-provider 归档后开始。 +- [coordinator 与现有 ChatRunRegistry 终态去重职责重叠] -> 去重防护保留为 invariant assertion 与兼容期保护,不作正常控制流(E13 迁移前确认位置)。 + +## 迁移计划 + +| 项 | 内容 | +|---|---| +| 上线步骤 | 1) 补 characterization tests;2) registry+capability 派生+facet optional 化;3) 身份唯一约束(先查重合并)+per-provider 游标+publisher port;4) typed runtime+coordinator+legacy adapter,5 provider 接入;5) agent/websocket 走 generic dispatcher,去 `@ts-nocheck`;6) frontend state 收敛 | +| 回滚策略 | 分阶段,每阶段可独立回滚代码;唯一约束迁移提供 down migration(删索引/表) | +| 回滚后数据处理 | 唯一约束回滚:删除新增索引即可,已合并的重复行**不自动拆回**——须人工核对(合并是有损操作)。`provider_scan_state` 回滚:保留表,回退读写到旧 `scan_state` | + +## 待明确问题 + +- 迁移顺序细节(capability 先还是身份先)可在 tasks 内按依赖微调,不改变 spec 与模块边界。 diff --git a/openspec/changes/refactor-provider-seams/proposal.md b/openspec/changes/refactor-provider-seams/proposal.md new file mode 100644 index 0000000000..88d081e98c --- /dev/null +++ b/openspec/changes/refactor-provider-seams/proposal.md @@ -0,0 +1,34 @@ +## Why + +现有 provider 集成的公共接缝在泄漏:`IProvider` 看似统一,实际是浅模块——新增或改动一个 provider 时,调用者仍需在多个中央位置了解该 provider 的差异(capability 静态矩阵、token usage 分支、watcher 硬编码路径、agent 路由 `if/else`、native session lookup 缺 provider)。这导致"加一个 provider = 1 处干净注册 + 8 处中央改动",且部分中央分支缺失时会**静默回退到 Claude**。 + +`add-pi-provider` 刻意沿用了现状、把这套重构切出去独立进行。本 change 就是被切出的另一半:把复杂度收回 provider 模块,使新增 provider 的行为变化集中在其自身目录与一次 registry 注册中。 + +**顺序依赖**:`add-pi-provider` 先落地(Pi 沿用现状 8 处分支)。本 change 在其后进行,范围包含把**已存在的全部 5 个 provider(claude/codex/cursor/opencode/pi)**迁移到新接缝。两者有中央文件交集,必须串行,不可并行。 + +## What Changes + +- registry 成为 provider descriptor、facet 与能力的唯一真相:`listProviders` / `resolveProvider` / `requireFacet`,注册时校验 descriptor。 +- capability response 从 facet 存在性派生(`supportsMcp = Boolean(provider.mcp)` 等),移除中央静态矩阵。 +- MCP、skills、token usage 改为 **optional facet**;不支持时返回 `PROVIDER_CAPABILITY_UNSUPPORTED`,与未注册 provider 的 `UNSUPPORTED_PROVIDER` 区分。**BREAKING**:`IProvider` 的 `mcp`/`skills` 由必选改为可选。 +- 引入 typed runtime 接缝(typed request / typed event sink / typed outcome)与 `ProviderRunCoordinator` 作为终态唯一所有者;现有 `.js` runtime 通过 `LegacyProviderRuntimeAdapter` 分阶段接入,不一次性重写。 +- native session 身份改为 `(provider, provider_session_id)`:新增唯一约束与 provider-qualified lookup/merge。**BREAKING**:`assignProviderSessionId` / `getSessionByProviderSessionId` 签名新增 `provider` 参数。 +- scan cursor 由全局单例改为 per-provider;watcher roots 由 synchronizer 动态提供;session 通知改为 application-owned publisher port,移除 providers→WebSocket 反向依赖。 +- Agent API 与 WebSocket 统一走 generic coordinator,移除逐 provider `queryX` 与 `if/else`;逐步从 `agent.routes.ts` 提取业务编排并移除 `@ts-nocheck`。 +- frontend 每-provider model state 收敛为 `Partial>`,行为由 backend capability 驱动。 + +## Capabilities + +### New Capabilities +- `provider-seams`: provider 公共接缝的对外行为契约——能力表达与 facet 存在性一致、unknown provider 与 unsupported facet 的错误码区分、跨 provider native session 身份隔离、per-provider 同步失败隔离、单一终态所有权。 + +### Modified Capabilities +- `pi-provider`: Pi 由"沿用现状中央分支"迁移到 optional facet + generic coordinator。仅当 `add-pi-provider` 已归档、其行为进入主 spec 后才产生 delta;本 change 不改变 Pi 的对外可观测行为,故此项为实现层迁移,不新增 Pi 行为需求。 + +## Impact + +- 数据库:新增 `(provider, provider_session_id)` 唯一约束迁移(迁移前须合并重复行);`scan_state` 单例迁移为 `provider_scan_state`。 +- 类型/契约:`server/shared/interfaces.ts`(facet 可选化 + typed runtime)、`server/shared/types.ts`。 +- Backend:`provider.registry.ts`、`provider-capabilities.service.ts`、`provider-token-usage.service.ts`、`sessions-watcher.service.ts`、`session-synchronizer.service.ts`、`sessions.db.ts`、`agent/agent.routes.ts`、`websocket/*`,以及 5 个 provider 的 runtime 适配。 +- Frontend:model state 收敛与 capability 驱动行为。 +- 交集与串行:与 `add-pi-provider` 的中央文件重叠,必须在其之后执行。 diff --git a/openspec/changes/refactor-provider-seams/specs/provider-seams/spec.md b/openspec/changes/refactor-provider-seams/specs/provider-seams/spec.md new file mode 100644 index 0000000000..cb9906d2e5 --- /dev/null +++ b/openspec/changes/refactor-provider-seams/specs/provider-seams/spec.md @@ -0,0 +1,87 @@ +## Purpose + +定义 provider 公共接缝重构后对外可见的行为契约:能力表达与 facet 存在性一致、未注册 provider 与不支持能力的错误码区分、跨 provider 的 native session 身份隔离、单个 provider 同步失败的隔离,以及一次 run 的单一终态所有权。仅登记对外可观测的行为变化,纯内部实现手段(typed runtime、generic dispatcher、前端 state 收敛)不进入本 spec。 + +## ADDED Requirements + +### Requirement: 能力表达与 facet 存在性一致 + +The system SHALL 使 provider 的能力描述从其 facet 的存在性派生:`supportsMcp`、`supportsSkills`、`supportsTokenUsage` 分别等于对应 optional facet 是否存在。 + +The system SHALL NOT 同时维护 facet 与第二份手写的 `supportsX` 真相;能力描述与 facet 存在性不一致时以 facet 为准。 + +#### Scenario: 具备 facet 的能力为真 +- **WHEN** 一个 provider 提供了 usage facet +- **THEN** 其能力描述中 `supportsTokenUsage` 为 `true` + +#### Scenario: 缺失 facet 的能力为假 +- **WHEN** 一个 provider 未提供 mcp facet +- **THEN** 其能力描述中 `supportsMcp` 为 `false` + +### Requirement: 未注册 provider 与不支持能力的错误码区分 + +The system SHALL 对"未注册的 provider"返回 `ERR-UNSUPPORTED-PROVIDER`,对"已注册但缺少某 facet"返回 `ERR-PROVIDER-CAPABILITY-UNSUPPORTED`,二者为不同的稳定错误。 + +The system SHALL NOT 用空成功结果把"不支持"伪装成"支持但无数据"。 + +#### Scenario: 未注册 provider +- **WHEN** 调用方以一个未注册的 provider id 请求任意 facet +- **THEN** 系统以 `ERR-UNSUPPORTED-PROVIDER` 拒绝 + +#### Scenario: 已注册但 facet 不支持 +- **WHEN** 调用方对一个已注册 provider 请求其未提供的 facet +- **THEN** 系统以 `ERR-PROVIDER-CAPABILITY-UNSUPPORTED` 拒绝,而非返回空成功 + +#### Scenario: 注册时 descriptor 非法 +- **WHEN** 注册一个默认权限模式不在其权限模式列表中的 provider +- **THEN** 系统在注册阶段以 `ERR-PROVIDER-DESCRIPTOR-INVALID` 拒绝,不进入可用集合 + +### Requirement: 跨 provider native session 身份隔离 + +The system SHALL 以 `(provider, provider_session_id)` 作为 native session 的唯一标识;所有 native session 的查找与合并 SHALL 携带 provider。 + +The system SHALL NOT 因两个不同 provider 拥有相同 native session id 字符串而将它们合并为同一行。 + +#### Scenario: 相同 native id 不同 provider 不合并 +- **WHEN** provider A 与 provider B 各有一个 native session id 相同的 session +- **THEN** 系统将其视为两个不同 session,不合并 + +#### Scenario: 同 provider 重复 native id +- **WHEN** 同一 provider 出现重复的 native session id +- **THEN** 系统按唯一约束拒绝或确定性合并为同一 app session + +#### Scenario: app id 与 native id 同值 +- **WHEN** 某 session 的 app id 与 native id 字符串相同 +- **THEN** 系统仍显式完成 mapping,不因字符串相同而跳过 DB 绑定 + +### Requirement: per-provider 同步失败隔离 + +The system SHALL 为每个 provider 维护独立的扫描游标,各 provider 在自身同步成功后独立推进其游标。 + +The system SHALL NOT 因某一个 provider 同步失败而阻止其他 provider 推进各自的游标。 + +#### Scenario: 单 provider 失败不影响他人游标 +- **WHEN** 某个 provider 的同步失败 +- **THEN** 其他 provider 的游标仍各自独立推进,仅失败 provider 的游标不推进 + +#### Scenario: 失败 provider 下轮重试 +- **WHEN** 失败的 provider 在下一轮扫描恢复 +- **THEN** 系统从其自身游标位置继续,不重扫其他 provider + +### Requirement: 单一终态所有权 + +The system SHALL 使 application 层的 coordinator 成为一次 run 终态(complete/aborted/failed)的唯一生产者;provider runtime 只产生非终态事件并返回 outcome。 + +The system SHALL NOT 允许 provider runtime、进程退出或 gateway 各自独立产生终态;一次 run 对外 SHALL 恰好观察到一个终态。 + +#### Scenario: 正常完成只有一个终态 +- **WHEN** 一次 run 正常结束 +- **THEN** 对外恰好观察到一个成功终态 + +#### Scenario: abort 与迟到事件竞争 +- **WHEN** abort 与 provider 迟到的 native 事件竞争 +- **THEN** 对外仍恰好观察到一个终态(aborted) + +#### Scenario: runtime 抛错或进程关闭 +- **WHEN** provider runtime 抛错或进程异常关闭 +- **THEN** coordinator 产生恰好一个失败终态 diff --git a/openspec/changes/refactor-provider-seams/tasks.md b/openspec/changes/refactor-provider-seams/tasks.md new file mode 100644 index 0000000000..835009592f --- /dev/null +++ b/openspec/changes/refactor-provider-seams/tasks.md @@ -0,0 +1,68 @@ +# Provider 公共接缝重构 实现任务 + +「构建什么」见 `specs/provider-seams/spec.md`,「怎么构建」见 `design.md`。 + +**前置硬约束**:本 change 必须在 `add-pi-provider` 归档后开始(中央文件交集,见 design 迁移计划)。characterization tests(任务组 1)必须先于任何替换完成并通过。 + +## 0. 文件归属 + +| 任务组 | 独占文件/目录 | 禁止改动 | 共享文件处理 | +|---|---|---|---| +| 1 | `server/modules/providers/tests/characterization/`、`server/modules/websocket/tests/` 新增用例 | 生产代码 | 只读生产代码,仅新增测试 | +| 2 | `provider.registry.ts`、`provider-capabilities.service.ts`、`server/shared/interfaces.ts`(facet 可选化)、`provider-token-usage.service.ts`、各 provider `*.provider.ts`(facet 挂载) | runtime `.js`、DB、agent 路由 | 串行在任务组 1 之后 | +| 3 | `sessions.db.ts`、`database/migrations.ts`、`database/schema.ts`、`session-synchronizer.service.ts`、`sessions-watcher.service.ts`、新增 publisher port | registry、runtime | 串行在任务组 2 之后 | +| 4 | 新增 `ProviderRunCoordinator`、`LegacyProviderRuntimeAdapter`、typed runtime 类型、5 个 runtime 的适配接入 | DB 迁移、registry | 串行在任务组 3 之后 | +| 5 | `agent/agent.routes.ts`、新增 agent application module、`websocket/services/*` | provider `list/` | 串行在任务组 4 之后 | +| 6 | `src/`(frontend model state 收敛与 capability 驱动) | backend | 无(可与 5 并行,写集不相交) | +| 7 | 无(只运行验证) | 全部 | 只读 | + +- [x] 任意两个任务组「独占文件」无交集(6 与 5 分属 src/ 与 backend)。 +- [x] 共享文件已串行:2→3→4→5 顺序依赖。 + +## 1. Characterization 基线(替换前必须完成) + +- [ ] 1.1 为 claude/codex/cursor/opencode 各录制 live event、resume、abort、history、usage、replay 的 golden 输出,落为可重跑的 characterization tests(对应 R15)。 +- [ ] 1.2 为「一次 run 恰好一个终态」录制现状基线(正常/abort/异常各一),作为 R10–R12 的回归锚点。 +- [ ] 1.3 全部基线测试通过并纳入 `npm test`;确认后方可进入替换。 + +## 2. Registry、capability 与 facet 可选化 + +- [ ] 2.1 在 `interfaces.ts` 引入 `ProviderDescriptor` 与 `ProviderDefinition`,将 `mcp`/`skills`/`usage` 改为 optional facet(**BREAKING**);运行 typecheck 得到全部待改实现点清单。 +- [ ] 2.2 重塑 `ProviderRegistry`:`listProviders`/`resolveProvider`/`requireFacet`、注册期 descriptor 校验(默认权限模式∈权限模式列表,否则 `ERR-PROVIDER-DESCRIPTOR-INVALID`)。补测 R2、R4。 +- [ ] 2.3 capability response 从 facet 存在性派生,删除中央静态矩阵;补测 R1、R3(能力与 facet 一致、unsupported facet 错误码)。 +- [ ] 2.4 `provider-token-usage.service.ts` 改为 optional usage facet 派发,移除逐 provider `if` 与 `.claude` 默认回退。 +- [ ] 2.5 5 个 provider 的 `*.provider.ts` 按 optional facet 重新挂载(不支持者不挂载对应 facet,删除临时空实现 adapter,如 Pi 的 unsupported mcp)。characterization(1.1)保持全绿。 + +## 3. Session 身份、per-provider 游标与通知端口 + +- [ ] 3.1 **迁移前**:对真实库查询 `(provider, provider_session_id)` 重复行(E12),记录并制定合并方案(一票否决门禁)。 +- [ ] 3.2 新增迁移:单事务内合并重复行 + 建部分唯一索引 `idx_sessions_provider_native_id`(`WHERE provider_session_id IS NOT NULL`);提供 down migration。 +- [ ] 3.3 `sessions.db.ts`:`assignProviderSessionId`/`getSessionByProviderSessionId` 签名加 `provider`(**BREAKING**),merge SQL 含 `provider = ?`。补测 R5、R6、R7。 +- [ ] 3.4 新增 `provider_scan_state` 表,`session-synchronizer.service.ts` 改 per-provider 游标独立推进;补测 R8、R9。 +- [ ] 3.5 `sessions-watcher.service.ts` 的 watch roots 改由各 synchronizer 提供,移除中央 `PROVIDER_WATCH_PATHS`。 +- [ ] 3.6 引入 application-owned session change publisher port(生产=WebSocket adapter,测试=内存),移除 providers→WebSocket 反向 import;补测 R16(依赖扫描)。 + +## 4. Typed runtime 与 coordinator + +- [ ] 4.1 定义 typed `ProviderRunRequest`/`IProviderEventSink`(类型层排除 `complete`/`session_created`)/`ProviderRunOutcome`(`server/shared/types.ts` + `interfaces.ts`)。 +- [ ] 4.2 实现 `ProviderRunCoordinator`:校验/身份/生命周期/**唯一终态**/replay 保留;成为终态唯一生产者。补测 R10、R11、R12。 +- [ ] 4.3 实现 `LegacyProviderRuntimeAdapter`:typed request↔旧 options、旧 writer event↔typed sink、`abort(sessionId)`↔`AbortSignal`,拦截旧 runtime 的 `complete/session_created`。补测 R13。 +- [ ] 4.4 5 个现有 runtime 经 legacy adapter 接入 coordinator;characterization(1.1、1.2)保持全绿。 + +## 5. Generic dispatcher 与去 @ts-nocheck + +- [ ] 5.1 从 `agent.routes.ts` 提取业务编排到 agent application module,route 只做解析/校验/调用/响应转换。 +- [ ] 5.2 Agent API 与 WebSocket 统一注入 generic coordinator,删除 `queryClaude/queryCursor/queryCodex/queryOpenCode/queryPi` 与逐 provider `if/else`。 +- [ ] 5.3 移除本 change 目标文件的 `@ts-nocheck`,typecheck 通过。 + +## 6. Frontend state 收敛 + +- [ ] 6.1 model state 由逐 provider(`claudeModel` 等)收敛为 `Partial>`,统一初始化/localStorage/catalog 校验/setter。 +- [ ] 6.2 permission/effort/usage/mcp/skills 显示改由 backend capability response 驱动;前端仅保留 logo/展示名等静态品牌映射。 + +## 7. 验证 + +- [ ] 7.1 characterization + 契约测试(R1–R16)全绿。 +- [ ] 7.2 `npm run build`、`npm run typecheck`、`npm run lint`、`npm test` 全绿。 +- [ ] 7.3 端到端冒烟:5 个 provider(含 Pi)新建/resume/abort/reconnect/history/sidebar/model restore 不回归。 +- [ ] 7.4 确认「加一个测试 provider」只需注册一次,无需改 capability/route parser/token service。 diff --git a/openspec/changes/refactor-provider-seams/test-definition.md b/openspec/changes/refactor-provider-seams/test-definition.md new file mode 100644 index 0000000000..c120d7b2f6 --- /dev/null +++ b/openspec/changes/refactor-provider-seams/test-definition.md @@ -0,0 +1,84 @@ +# Provider 公共接缝重构 测试定义 + +依据 `specs/provider-seams/spec.md`。本 change 是重构,最大风险是**回归**(改动 5 个 provider 的公共接缝)与**不可逆数据迁移**(唯一约束合并)。因此测试分两类:锁定既有行为的 characterization tests,与验证新契约的 spec tests。 + +## 测试目标与边界 + +**范围内:** +- 重构前后现有 4 个 provider 的 observable 行为一致(live event、resume、abort、history、usage、replay)。 +- 新契约:能力与 facet 一致、unknown vs unsupported 错误码、native 身份隔离、per-provider 游标、单一终态。 +- 唯一约束迁移:重复行合并的正确性与 provider-qualified 隔离。 + +**范围外:** +- Pi 的功能行为(由 `add-pi-provider` 覆盖);本 change 只验证 Pi 迁入新接缝后 observable 不变。 +- provider 上游 CLI 行为。 + +## 覆盖策略 + +| 维度 | 是否覆盖 | 样本数 | 说明 | +|---|---|---|---| +| 正常路径 | 是 | 6 | 五能力契约正常场景 + 4 provider 各自 characterization 冒烟 | +| 异常 | 是 | 5 | unknown provider、unsupported facet、descriptor 非法、runtime 抛错终态、同步单点失败 | +| 边界 | 是 | 4 | app id=native id 同值、相同 native id 跨 provider、部分索引空 native、失败 provider 下轮恢复 | +| 对抗 | 是 | 3 | abort 与迟到 native event 竞争、迁移遇真实重复行、runtime 试图发 complete 被拦 | +| 高风险 | 是 | 4 | 唯一约束迁移合并正确性、终态唯一性、providers→WS 反向依赖移除、4 provider 零回归 | + +## 评测集 + +| 编号 | 输入 | 预期 | 维度 | 来源 | +|---|---|---|---|---| +| R1 | provider 有 usage facet / 无 mcp facet | `supportsTokenUsage=true` / `supportsMcp=false` | 正常 | spec: 能力一致 | +| R2 | 未注册 provider id 请求 facet | `ERR-UNSUPPORTED-PROVIDER` | 异常 | spec: 错误码区分 | +| R3 | 已注册 provider 请求缺失 facet | `ERR-PROVIDER-CAPABILITY-UNSUPPORTED` | 异常 | spec: 错误码区分 | +| R4 | 默认权限模式不在列表的 descriptor | 注册期 `ERR-PROVIDER-DESCRIPTOR-INVALID` | 异常 | spec: 错误码区分 | +| R5 | provider A、B 相同 native id | 视为两个 session,不合并 | 边界/高风险 | spec: 身份隔离 | +| R6 | 同 provider 重复 native id | 唯一约束拒绝或确定性合并 | 异常 | spec: 身份隔离 | +| R7 | app id 与 native id 同值 | 仍完成 DB mapping | 边界 | spec: 身份隔离 | +| R8 | 一个 provider 同步失败 | 其他 provider 游标独立推进 | 异常 | spec: 同步隔离 | +| R9 | 失败 provider 下轮恢复 | 从自身游标续扫,不重扫他人 | 边界 | spec: 同步隔离 | +| R10 | 一次 run 正常结束 | 恰好一个成功终态 | 正常 | spec: 单一终态 | +| R11 | abort 与迟到 native event 竞争 | 恰好一个 aborted 终态 | 对抗 | spec: 单一终态 | +| R12 | runtime 抛错/进程异常关闭 | coordinator 产生恰好一个失败终态 | 异常 | spec: 单一终态 | +| R13 | legacy runtime 试图发 `complete` | 被 adapter/类型拦截,不产生第二终态 | 对抗 | spec: 单一终态 | +| R14 | 真实库存在跨 provider 重复 native id,执行迁移 | provider-qualified 合并,不跨 provider 误并 | 高风险 | design: 迁移 | +| R15 | claude/codex/cursor/opencode 各自 characterization 冒烟 | 重构前后 observable 一致 | 正常/高风险 | design: 兼容性 | +| R16 | providers 模块静态依赖扫描 | 无 providers→WebSocket import | 高风险 | design: 依赖单向 | + +## 评分规则 + +二值判定,断言全满足=通过。characterization tests(R15)以重构前录制的 golden 输出为基准逐条比对。逐样本记录含实际错误码/终态数量/依赖扫描结果与失败归因,留存于测试输出。放行看分维度门槛。 + +## 验收规则与回归门槛 + +| 规则 | 门槛 | 适用范围 | +|---|---|---| +| 4 provider 零回归 | 100% | R15(characterization 全绿) | +| 高风险全通过 | 100% | R5、R14、R16 及终态唯一性 R11 | +| 契约异常全通过 | 100% | R2、R3、R4、R6、R12(错误码精确匹配) | +| 单一终态 | 100% | R10、R11、R12、R13 | +| 边界 | ≥ 90% | R7、R9 等 | + +## 上线门禁 + +- [ ] characterization tests 在**重构前**先建立并通过(golden 基准存在),否则不得开始替换。 +- [ ] `npm run build`、`npm run typecheck`、`npm run lint`、`npm test` 全绿。 +- [ ] 唯一约束迁移前已对真实库查询确认重复行情况(E12),合并在单事务内完成且可回滚。 +- [ ] 一次 run 恰好一个终态(R10–R13 全通过)。 +- [ ] providers 模块无对 WebSocket 的反向 import(R16)。 +- [ ] 4 个现有 provider + Pi 的端到端冒烟不回归。 +- [ ] `@ts-nocheck` 在本 change 范围内的目标文件已移除且 typecheck 通过。 + +## 报告审核清单 + +| 审核项 | 必须确认 | 不通过情形 | 结论影响 | +|---|---|---|---| +| 场景覆盖 | 五维均覆盖 | 只跑契约正常路径,缺 characterization | 不得放行 | +| 样本结构 | characterization 基于重构前真实录制 | 用重构后代码反推 golden | 报告不可信 | +| 评分规则 | 逐样本记录含错误码/终态计数/归因 | 只有总分 | 结论不可审计 | +| 验收规则 | 各维度达门槛 | 高风险或零回归未满足 | 不能判定通过 | +| 迁移证据 | 真实库重复行查询与合并结果留证 | 直接建索引未查重 | 直接不放行 | +| 问题归因 | 每个失败归因到 spec/设计/编排/数据 | 只写「重构副作用」 | 无法闭环 | +| 回归结果 | 修复后重跑并附证据 | 只有修复说明 | 问题不得关闭 | +| 门禁结论 | 全部硬门禁满足 | 任一未达标 | 直接不放行 | + +**结论:** 待执行 — 依上述门禁判定。zero-regression 与迁移查重为一票否决项。 diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000000..392946c67c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/package-lock.json b/package-lock.json index 5a559b4123..761c5cd36d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@codemirror/lang-python": "^6.2.1", "@codemirror/merge": "^6.11.1", "@codemirror/theme-one-dark": "^6.1.2", + "@earendil-works/pi-coding-agent": "0.83.0", "@iarna/toml": "^2.2.5", "@octokit/rest": "^22.0.0", "@openai/codex-sdk": "^0.144.0", @@ -1130,6 +1131,1825 @@ "node": ">=10" } }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.83.0", + "resolved": "https://registry.npmmirror.com/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.83.0.tgz", + "integrity": "sha512-uYhF+FsZxogoSX/AxBcUdiY+ZklubwaXyAoEGA2eQwsHcyEAhUYIKh/WLXe/a8+k8eTCmxb+ZN2Zo9mzQtzbWw==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.83.0", + "@earendil-works/pi-ai": "^0.83.0", + "@earendil-works/pi-tui": "^0.83.0", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.5.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.83.0.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.83.0", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.83.0.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.83.0.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -8599,7 +10419,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -8616,7 +10435,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -12694,7 +14512,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/gray-matter": { @@ -12751,7 +14568,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13084,7 +14900,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", - "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^10.0.1" @@ -13097,7 +14912,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/html-parse-stringify": { @@ -18042,7 +19856,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -18054,7 +19867,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, "license": "ISC" }, "node_modules/property-information": { @@ -19173,7 +20985,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -19564,7 +21375,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -22279,7 +24089,6 @@ "version": "6.21.3", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index 92dcf6d7b3..c094278f54 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "url": "https://github.com/siteboon/claudecodeui/issues" }, "scripts": { - "dev": "concurrently --kill-others \"npm run server:dev\" \"npm run client\"", + "dev": "concurrently --kill-others \"npm run server:dev-watch\" \"npm run client\"", "server": "node dist-server/server/index.js", "server:dev": "tsx --tsconfig server/tsconfig.json server/index.ts", "server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.ts", @@ -144,6 +144,7 @@ "@codemirror/lang-python": "^6.2.1", "@codemirror/merge": "^6.11.1", "@codemirror/theme-one-dark": "^6.1.2", + "@earendil-works/pi-coding-agent": "0.83.0", "@iarna/toml": "^2.2.5", "@octokit/rest": "^22.0.0", "@openai/codex-sdk": "^0.144.0", diff --git a/server/index.ts b/server/index.ts index fac4734363..a04292e790 100644 --- a/server/index.ts +++ b/server/index.ts @@ -83,6 +83,7 @@ const queryClaude = providerRuntimeService.getRunner('claude'); const queryCursor = providerRuntimeService.getRunner('cursor'); const queryCodex = providerRuntimeService.getRunner('codex'); const queryOpenCode = providerRuntimeService.getRunner('opencode'); +const queryPi = providerRuntimeService.getRunner('pi'); const gitRoutes = createGitModule({ queryClaude, queryCursor, @@ -92,6 +93,7 @@ const agentRoutes = createAgentModule({ queryCursor, queryCodex, queryOpenCode, + queryPi, }); // Single WebSocket server that handles chat, shell, and plugin proxy paths. diff --git a/server/modules/agent/agent.module.ts b/server/modules/agent/agent.module.ts index 144781162c..ab603c27ff 100644 --- a/server/modules/agent/agent.module.ts +++ b/server/modules/agent/agent.module.ts @@ -17,7 +17,7 @@ import { createAgentRouter } from './agent.routes.js'; type AgentExternalDependencies = Pick< Parameters[0], - 'queryClaude' | 'queryCursor' | 'queryCodex' | 'queryOpenCode' + 'queryClaude' | 'queryCursor' | 'queryCodex' | 'queryOpenCode' | 'queryPi' >; /** diff --git a/server/modules/agent/agent.routes.ts b/server/modules/agent/agent.routes.ts index 214d21f4cc..7c24a3e05d 100644 --- a/server/modules/agent/agent.routes.ts +++ b/server/modules/agent/agent.routes.ts @@ -22,6 +22,7 @@ type AgentRouterDependencies = { queryCursor: ProviderRunFunction; queryCodex: ProviderRunFunction; queryOpenCode: ProviderRunFunction; + queryPi: ProviderRunFunction; GithubClient: typeof import('@octokit/rest').Octokit; }; @@ -44,6 +45,7 @@ export function createAgentRouter(dependencies: AgentRouterDependencies): expres const spawnCursor = dependencies.queryCursor; const queryCodex = dependencies.queryCodex; const spawnOpenCode = dependencies.queryOpenCode; + const queryPi = dependencies.queryPi; const Octokit = dependencies.GithubClient; const router = express.Router(); @@ -662,7 +664,7 @@ export function createAgentRouter(dependencies: AgentRouterDependencies): expres * - Source for auto-generated branch names (if createBranch=true and no branchName) * - Fallback for PR title if no commits are made * - * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'opencode' + * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'opencode' | 'pi' * Default: 'claude' * * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates. @@ -896,8 +898,8 @@ export function createAgentRouter(dependencies: AgentRouterDependencies): expres return res.status(400).json({ error: 'message is required' }); } - if (!['claude', 'cursor', 'codex', 'opencode'].includes(provider)) { - return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "opencode"' }); + if (!['claude', 'cursor', 'codex', 'opencode', 'pi'].includes(provider)) { + return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "opencode", or "pi"' }); } // Validate GitHub branch/PR creation requirements @@ -1026,6 +1028,18 @@ export function createAgentRouter(dependencies: AgentRouterDependencies): expres effort, permissionMode: 'bypassPermissions' // Agent runs are non-interactive, like the other providers above }, writer); + } else if (provider === 'pi') { + console.log('Starting Pi CLI session'); + + const piModels = (await providerModelsService.getProviderModels('pi')).models; + await queryPi(message.trim(), { + projectPath: finalProjectPath, + cwd: finalProjectPath, + sessionId: sessionId || null, + model: model || piModels.DEFAULT, + effort, + permissionMode: 'bypassPermissions' + }, writer); } // Handle GitHub branch and PR creation after successful agent completion diff --git a/server/modules/agent/tests/agent.routes.test.ts b/server/modules/agent/tests/agent.routes.test.ts index eeeb4cbe8d..26a283bc75 100644 --- a/server/modules/agent/tests/agent.routes.test.ts +++ b/server/modules/agent/tests/agent.routes.test.ts @@ -34,6 +34,7 @@ function createDependencies( queryCursor: unexpectedProviderCall as AgentDependencies['queryCursor'], queryCodex: unexpectedProviderCall as AgentDependencies['queryCodex'], queryOpenCode: unexpectedProviderCall as AgentDependencies['queryOpenCode'], + queryPi: unexpectedProviderCall as AgentDependencies['queryPi'], GithubClient: class {} as unknown as AgentDependencies['GithubClient'], ...overrides, }; @@ -203,3 +204,34 @@ test('Agent route reuses a matching checkout without cloning or deleting it', as assert.deepEqual(spawnedArguments, [['config', '--get', 'remote.origin.url']]); assert.deepEqual(removedPaths, []); }); + +test('Agent route dispatches to the Pi provider runner with its default model', async () => { + const piCalls: Array<{ command: string; model: string }> = []; + + await withAgentServer(createDependencies({ + fileSystem: { + access: async () => undefined, + rm: async () => undefined, + } as unknown as AgentDependencies['fileSystem'], + models: { + getProviderModels: async () => ({ models: { DEFAULT: 'pi-default-model' } }), + } as unknown as AgentDependencies['models'], + queryPi: (async (command: string, options: { model: string }) => { + piCalls.push({ command, model: options.model }); + }) as unknown as AgentDependencies['queryPi'], + }), async (baseUrl) => { + const response = await fetch(`${baseUrl}/api/agent`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + projectPath: '/home/test/.claude/external-projects/pi-project', + message: 'Run Pi', + provider: 'pi', + stream: false, + }), + }); + assert.equal(response.status, 200); + }); + + assert.deepEqual(piCalls, [{ command: 'Run Pi', model: 'pi-default-model' }]); +}); diff --git a/server/modules/commands/commands.routes.ts b/server/modules/commands/commands.routes.ts index f51415b80b..8a7c6f82af 100644 --- a/server/modules/commands/commands.routes.ts +++ b/server/modules/commands/commands.routes.ts @@ -28,13 +28,14 @@ const providerModelsService = dependencies.models; const process = dependencies.runtime; const router = express.Router(); -const MODEL_PROVIDERS = ["claude", "cursor", "codex", "opencode"]; +const MODEL_PROVIDERS = ["claude", "cursor", "codex", "opencode", "pi"]; const MODEL_PROVIDER_LABELS = { claude: "Claude", cursor: "Cursor", codex: "Codex", opencode: "OpenCode", + pi: "Pi", }; const readModelProvider = (value) => { diff --git a/server/modules/commands/tests/commands.test.ts b/server/modules/commands/tests/commands.test.ts index 2f80d5e495..cde3616353 100644 --- a/server/modules/commands/tests/commands.test.ts +++ b/server/modules/commands/tests/commands.test.ts @@ -83,6 +83,17 @@ test('models command returns models only for the active provider using injected assert.deepEqual(Object.keys(data.available as object), ['codex']); }); +test('models command keeps Pi as the active provider', async () => { + const result = await executeCommand('/models', { provider: 'pi' }); + const data = result.data as { + current: { provider: string; providerLabel: string }; + available: Record; + }; + + assert.deepEqual(data.current, { provider: 'pi', providerLabel: 'Pi', model: 'default' }); + assert.deepEqual(Object.keys(data.available), ['pi']); +}); + test('models command falls back to claude for unsupported providers', async () => { const result = await executeCommand('/models', { provider: 'unknown-provider' }); const data = result.data as { current: { provider: string } }; diff --git a/server/modules/providers/list/claude/claude-runtime.provider.js b/server/modules/providers/list/claude/claude-runtime.provider.js index ffb4155ff9..51704d40b7 100644 --- a/server/modules/providers/list/claude/claude-runtime.provider.js +++ b/server/modules/providers/list/claude/claude-runtime.provider.js @@ -209,7 +209,13 @@ function mapCliOptionsToSDK(options = {}) { sdkOptions.disallowedTools = settings.disallowedTools || []; - sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT; + // Prefer the locally configured model (ANTHROPIC_MODEL from Claude Code + // settings) so the app always matches the host CLI. When it is unset, fall + // back to the model picked in the web UI, then the provider catalog default. + sdkOptions.model = + process.env.ANTHROPIC_MODEL?.trim() || + options.model || + CLAUDE_FALLBACK_MODELS.DEFAULT; const resolvedEffort = resolveClaudeEffort( sdkOptions.model, diff --git a/server/modules/providers/list/pi/index.ts b/server/modules/providers/list/pi/index.ts new file mode 100644 index 0000000000..59be1ad708 --- /dev/null +++ b/server/modules/providers/list/pi/index.ts @@ -0,0 +1,7 @@ +/** + * Public surface of the Pi provider for the central registry. + * + * Only the assembled provider class is exported; individual facets stay + * internal to this module. + */ +export { PiProvider } from './pi.provider.js'; diff --git a/server/modules/providers/list/pi/pi-auth.provider.test.ts b/server/modules/providers/list/pi/pi-auth.provider.test.ts new file mode 100644 index 0000000000..adf895777a --- /dev/null +++ b/server/modules/providers/list/pi/pi-auth.provider.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { ModelInfo, RpcClientOptions } from '@earendil-works/pi-coding-agent'; + +import { PiRpcClient } from './pi-rpc-client.provider.js'; +import { PiAuthProvider } from './pi-auth.provider.js'; + +const MODEL: ModelInfo = { provider: 'p', id: 'm1', contextWindow: 1000, reasoning: false }; + +function fakeSpawnSync(status: number | null, error?: Error) { + return (() => ({ status, error, pid: 1, output: [], stdout: '', stderr: '', signal: null })) as unknown as typeof import('cross-spawn').sync; +} + +/** A probe rpc client stub honouring the PiAuthProvider surface. */ +function makeRpcStub(opts: { models?: ModelInfo[]; throwOnModels?: boolean } = {}) { + return { + start: async () => {}, + getAvailableModels: async () => { + if (opts.throwOnModels) throw new Error('probe failed'); + return opts.models ?? [MODEL]; + }, + close: async () => {}, + }; +} + +// T12: --version fails -> not installed, not authenticated, no throw. +test('T12 executable --version failing reports not installed without throwing', async () => { + const provider = new PiAuthProvider({ + paths: { getCliPath: () => 'pi' }, + spawnSync: fakeSpawnSync(1), + createRpcClient: () => makeRpcStub(), + }); + + const status = await provider.getStatus(); + assert.equal(status.installed, false); + assert.equal(status.authenticated, false); + assert.equal(status.provider, 'pi'); +}); + +// T13: installed but probe yields no models (or throws) -> installed, not authenticated, no throw. +test('T13 installed but probe with no models reports not authenticated', async () => { + const provider = new PiAuthProvider({ + paths: { getCliPath: () => 'pi' }, + spawnSync: fakeSpawnSync(0), + createRpcClient: () => makeRpcStub({ models: [] }), + }); + + const status = await provider.getStatus(); + assert.equal(status.installed, true); + assert.equal(status.authenticated, false); +}); + +test('T13b installed but probe throwing reports not authenticated without throwing', async () => { + const provider = new PiAuthProvider({ + paths: { getCliPath: () => 'pi' }, + spawnSync: fakeSpawnSync(0), + createRpcClient: () => makeRpcStub({ throwOnModels: true }), + }); + + const status = await provider.getStatus(); + assert.equal(status.installed, true); + assert.equal(status.authenticated, false); +}); + +test('installed and probe with >=1 model reports authenticated', async () => { + const provider = new PiAuthProvider({ + paths: { getCliPath: () => 'pi' }, + spawnSync: fakeSpawnSync(0), + createRpcClient: () => makeRpcStub({ models: [MODEL] }), + }); + + const status = await provider.getStatus(); + assert.equal(status.installed, true); + assert.equal(status.authenticated, true); +}); + +// T28: probe flags must equal runtime flags, both containing --no-extensions. +test('T28 probe uses same flags as runtime including --no-extensions', async () => { + let capturedArgs: string[] | undefined; + + // The default createRpcClient builds a real PiRpcClient, which injects the + // fixed runtime flags. Wiring a capturing underlying-client deps lets us + // observe the exact argv the probe would spawn with. + const provider = new PiAuthProvider({ + paths: { getCliPath: () => 'pi' }, + spawnSync: fakeSpawnSync(0), + createRpcClient: (options: RpcClientOptions) => + new PiRpcClient(options, { + createClient: (built) => { + capturedArgs = built.args; + return { + start: async () => {}, + stop: async () => {}, + onEvent: () => () => {}, + getStderr: () => '', + prompt: async () => {}, + abort: async () => {}, + getState: async () => ({}) as never, + getAvailableModels: async () => [MODEL], + getCommands: async () => [], + }; + }, + }), + }); + + const status = await provider.getStatus(); + assert.equal(status.authenticated, true); + assert.ok(capturedArgs, 'probe should spawn through PiRpcClient'); + assert.ok( + capturedArgs?.includes('--no-extensions'), + 'probe flags must contain --no-extensions (same as runtime)', + ); +}); diff --git a/server/modules/providers/list/pi/pi-auth.provider.ts b/server/modules/providers/list/pi/pi-auth.provider.ts new file mode 100644 index 0000000000..070ac40b7f --- /dev/null +++ b/server/modules/providers/list/pi/pi-auth.provider.ts @@ -0,0 +1,111 @@ +/** + * PiProviderAuth - reports Pi installation and authentication status. + * + * Installation is determined by running the Pi executable with `--version`. + * Authentication is determined by launching an RPC probe through + * {@link PiRpcClient} - which injects the same runtime flags (including + * `--no-extensions`) - and checking that at least one model is available. + * + * Normal "not installed" / "not authenticated" states return without throwing. + */ +import spawn from 'cross-spawn'; +import type { RpcClientOptions } from '@earendil-works/pi-coding-agent'; + +import type { IProviderAuth } from '@/shared/interfaces.js'; +import type { ProviderAuthStatus } from '@/shared/types.js'; + +import { PiPaths } from './pi-paths.provider.js'; +import { PiRpcClient } from './pi-rpc-client.provider.js'; + +const VERSION_TIMEOUT_MS = 5000; +const PROBE_GRACE_MS = 5000; + +/** Minimal probe surface the auth check depends on. */ +interface AuthProbeClient { + start(): Promise; + getAvailableModels(): Promise; + close(graceMs: number): Promise; +} + +interface PiAuthDeps { + paths: Pick; + spawnSync: typeof spawn.sync; + createRpcClient: (options: RpcClientOptions) => AuthProbeClient; +} + +const defaultDeps: PiAuthDeps = { + paths: new PiPaths(), + spawnSync: spawn.sync, + createRpcClient: (options) => new PiRpcClient(options), +}; + +export class PiAuthProvider implements IProviderAuth { + private readonly deps: PiAuthDeps; + + constructor(deps: Partial = {}) { + this.deps = { ...defaultDeps, ...deps }; + } + + async getStatus(): Promise { + const installed = this.checkInstalled(); + if (!installed) { + return { + installed: false, + provider: 'pi', + authenticated: false, + email: null, + method: null, + error: 'Pi CLI not installed', + }; + } + + const authenticated = await this.probeAuthenticated(); + return { + installed: true, + provider: 'pi', + authenticated, + email: null, + method: authenticated ? 'rpc_probe' : null, + error: authenticated ? undefined : 'Not authenticated', + }; + } + + /** True when `pi --version` exits successfully. */ + private checkInstalled(): boolean { + try { + const cliPath = this.deps.paths.getCliPath(); + const result = this.deps.spawnSync(cliPath, ['--version'], { + stdio: 'ignore', + timeout: VERSION_TIMEOUT_MS, + }); + return !result.error && result.status === 0; + } catch { + return false; + } + } + + /** + * Launches an RPC probe using the same flags as the runtime (PiRpcClient + * injects `--no-extensions`) and reports authenticated when at least one + * model is returned. Any failure is treated as "not authenticated". + */ + private async probeAuthenticated(): Promise { + // cliPath is intentionally omitted: PiRpcClient defaults to the JS entry + // (dist/cli.js) via PiPaths.getRpcCliEntry(). Passing getCliPath() here + // would spawn `node pi` and crash. + const client = this.deps.createRpcClient({}); + try { + await client.start(); + const models = await client.getAvailableModels(); + return models.length >= 1; + } catch { + return false; + } finally { + try { + await client.close(PROBE_GRACE_MS); + } catch { + // ignore close failures during probe teardown + } + } + } +} diff --git a/server/modules/providers/list/pi/pi-mcp.provider.test.ts b/server/modules/providers/list/pi/pi-mcp.provider.test.ts new file mode 100644 index 0000000000..ce99672930 --- /dev/null +++ b/server/modules/providers/list/pi/pi-mcp.provider.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { PiMcpProvider } from '@/modules/providers/list/pi/pi-mcp.provider.js'; +import { AppError } from '@/shared/utils.js'; + +const ALL_SCOPES = ['user', 'local', 'project'] as const; + +test('T26: listServers returns a complete grouping with an empty array per scope', async () => { + const provider = new PiMcpProvider(); + const grouped = await provider.listServers(); + + assert.deepEqual(Object.keys(grouped).sort(), [...ALL_SCOPES].sort()); + for (const scope of ALL_SCOPES) { + assert.deepEqual(grouped[scope], []); + } +}); + +test('T26: listServersForScope returns an empty array for every scope', async () => { + const provider = new PiMcpProvider(); + for (const scope of ALL_SCOPES) { + assert.deepEqual(await provider.listServersForScope(scope), []); + } +}); + +test('T26: upsertServer throws ERR-PROVIDER-CAPABILITY-UNSUPPORTED', async () => { + const provider = new PiMcpProvider(); + await assert.rejects( + () => provider.upsertServer({ name: 'x', transport: 'stdio', command: 'foo' }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'PROVIDER_CAPABILITY_UNSUPPORTED'); + assert.equal(error.statusCode, 400); + return true; + }, + ); +}); + +test('T26: removeServer throws ERR-PROVIDER-CAPABILITY-UNSUPPORTED', async () => { + const provider = new PiMcpProvider(); + await assert.rejects( + () => provider.removeServer({ name: 'x' }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'PROVIDER_CAPABILITY_UNSUPPORTED'); + assert.equal(error.statusCode, 400); + return true; + }, + ); +}); diff --git a/server/modules/providers/list/pi/pi-mcp.provider.ts b/server/modules/providers/list/pi/pi-mcp.provider.ts new file mode 100644 index 0000000000..899fc03918 --- /dev/null +++ b/server/modules/providers/list/pi/pi-mcp.provider.ts @@ -0,0 +1,51 @@ +import type { IProviderMcp } from '@/shared/interfaces.js'; +import type { + LLMProvider, + McpScope, + ProviderMcpServer, + UpsertProviderMcpServerInput, +} from '@/shared/types.js'; +import { AppError } from '@/shared/utils.js'; + +/** Every MCP scope the shared contract defines. */ +const ALL_SCOPES: readonly McpScope[] = ['user', 'local', 'project']; + +const unsupported = (): never => { + throw new AppError('该 provider 不支持此能力', { + code: 'PROVIDER_CAPABILITY_UNSUPPORTED', + statusCode: 400, + }); +}; + +/** + * MCP adapter for Pi. + * + * Pi does not support MCP servers, so reads return a complete grouping with an + * empty array for every scope, and writes throw + * `ERR-PROVIDER-CAPABILITY-UNSUPPORTED`. + */ +export class PiMcpProvider implements IProviderMcp { + async listServers(): Promise> { + return ALL_SCOPES.reduce((grouped, scope) => { + grouped[scope] = []; + return grouped; + }, {} as Record); + } + + async listServersForScope( + _scope: McpScope, + _options?: { workspacePath?: string }, + ): Promise { + return []; + } + + async upsertServer(_input: UpsertProviderMcpServerInput): Promise { + return unsupported(); + } + + async removeServer( + _input: { name: string; scope?: McpScope; workspacePath?: string }, + ): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> { + return unsupported(); + } +} diff --git a/server/modules/providers/list/pi/pi-models.provider.test.ts b/server/modules/providers/list/pi/pi-models.provider.test.ts new file mode 100644 index 0000000000..6a43ea256c --- /dev/null +++ b/server/modules/providers/list/pi/pi-models.provider.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PiModelsProvider, type PiModelsProbe } from './pi-models.provider.js'; + +type ModelRow = { provider: string; id: string; contextWindow: number; reasoning: boolean }; + +const makeProbe = (models: ModelRow[], defaultModel?: string): PiModelsProbe => ({ + async getAvailableModels() { + return models; + }, + async getState() { + return { model: defaultModel }; + }, +}); + +const makeProvider = (probe: PiModelsProbe): PiModelsProvider => + new PiModelsProvider({ + async withProbe(fn) { + return fn(probe); + }, + }); + +// T10 — get_available_models probe → canonical 列表 + 默认,reasoning 有 effort。 +test('T10 supported models are canonical with reasoning-only effort and state default', async () => { + const provider = makeProvider( + makeProbe( + [ + { provider: 'anthropic', id: 'claude-sonnet', contextWindow: 200000, reasoning: true }, + { provider: 'openai', id: 'gpt-basic', contextWindow: 128000, reasoning: false }, + ], + 'openai/gpt-basic', + ), + ); + + const catalog = await provider.getSupportedModels(); + + assert.deepEqual( + catalog.OPTIONS.map((o) => o.value), + ['anthropic/claude-sonnet', 'openai/gpt-basic'], + ); + + const reasoningOption = catalog.OPTIONS.find((o) => o.value === 'anthropic/claude-sonnet'); + const plainOption = catalog.OPTIONS.find((o) => o.value === 'openai/gpt-basic'); + assert.ok(reasoningOption?.effort, 'reasoning model exposes thinking effort'); + assert.ok(reasoningOption.effort.values.length > 0); + assert.equal(plainOption?.effort, undefined, 'non-reasoning model has no effort'); + + assert.equal(catalog.DEFAULT, 'openai/gpt-basic'); +}); + +// T10 变体 — 无 state.model 时回退目录首项。 +test('T10 default falls back to first option when state has no model', async () => { + const provider = makeProvider( + makeProbe([{ provider: 'anthropic', id: 'claude-a', contextWindow: 1, reasoning: false }]), + ); + + const catalog = await provider.getSupportedModels(); + assert.equal(catalog.DEFAULT, 'anthropic/claude-a'); +}); + +// T11 — 未认证(probe 无模型)→ ERR-PI-NOT-AUTHENTICATED,不冒充空目录。 +test('T11 empty probe surfaces PI_NOT_AUTHENTICATED instead of empty catalog', async () => { + const provider = makeProvider(makeProbe([])); + await assert.rejects( + () => provider.getSupportedModels(), + (err: unknown) => (err as { code?: string }).code === 'PI_NOT_AUTHENTICATED', + ); +}); + +// T11 — probe 抛错映射为 PI_NOT_AUTHENTICATED。 +test('T11 probe failure maps to PI_NOT_AUTHENTICATED', async () => { + const provider = new PiModelsProvider({ + async withProbe() { + throw new Error('spawn probe failed'); + }, + }); + await assert.rejects( + () => provider.getSupportedModels(), + (err: unknown) => (err as { code?: string }).code === 'PI_NOT_AUTHENTICATED', + ); +}); + +// getCurrentActiveModel 只读,回退目录默认。 +test('getCurrentActiveModel returns catalog default', async () => { + const provider = makeProvider( + makeProbe( + [{ provider: 'anthropic', id: 'claude-a', contextWindow: 1, reasoning: false }], + 'anthropic/claude-a', + ), + ); + const active = await provider.getCurrentActiveModel(); + assert.equal(active.model, 'anthropic/claude-a'); +}); diff --git a/server/modules/providers/list/pi/pi-models.provider.ts b/server/modules/providers/list/pi/pi-models.provider.ts new file mode 100644 index 0000000000..27eb95d4b3 --- /dev/null +++ b/server/modules/providers/list/pi/pi-models.provider.ts @@ -0,0 +1,101 @@ +/** + * PiModelsProvider - Pi model catalog facet (IProviderModels). + * + * Resolves the catalog from a Pi RPC probe: `getAvailableModels()` supplies the + * selectable models and `getState().model` supplies the default. Models are + * exposed with the canonical `/` value; only models + * that report `reasoning: true` expose thinking effort levels. + * + * The probe is treated as an authentication signal: a probe that throws or + * returns no models is surfaced as `PI_NOT_AUTHENTICATED` rather than a fake + * empty catalog, so callers never mistake "not authenticated" for "success". + */ +import type { IProviderModels } from '@/shared/interfaces.js'; +import type { + ProviderCurrentActiveModel, + ProviderModelOption, + ProviderModelsDefinition, +} from '@/shared/types.js'; +import { AppError, buildDefaultProviderCurrentActiveModel } from '@/shared/utils.js'; + +/** One model row as returned by the Pi RPC `get_available_models` call. */ +export type PiModelRow = { + provider: string; + id: string; + contextWindow: number; + reasoning: boolean; +}; + +/** Minimal probe surface consumed to build the catalog. */ +export interface PiModelsProbe { + getAvailableModels(): Promise; + getState(): Promise<{ model?: string }>; +} + +/** + * Runs `fn` against a started Pi RPC probe, handling lifecycle. Tests inject a + * stub that resolves against an in-memory probe without spawning a process. + */ +export interface PiModelsRpc { + withProbe(fn: (probe: PiModelsProbe) => Promise): Promise; +} + +// Thinking effort levels Pi exposes for reasoning-capable models. Mirrors the +// Pi `ThinkingLevel` union (excluding the "off" no-op level). +const PI_THINKING_EFFORTS: NonNullable['values'] = [ + { value: 'minimal' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'xhigh' }, + { value: 'max' }, +]; + +const toCanonical = (row: PiModelRow): string => `${row.provider}/${row.id}`; + +const mapModel = (row: PiModelRow): ProviderModelOption => ({ + value: toCanonical(row), + label: row.id, + description: `${row.provider} - ${toCanonical(row)}`, + effort: row.reasoning ? { values: PI_THINKING_EFFORTS } : undefined, +}); + +export class PiModelsProvider implements IProviderModels { + private readonly rpc: PiModelsRpc; + + constructor(rpc: PiModelsRpc) { + this.rpc = rpc; + } + + async getSupportedModels(): Promise { + let rows: PiModelRow[]; + let stateModel: string | undefined; + + try { + ({ rows, stateModel } = await this.rpc.withProbe(async (probe) => ({ + rows: await probe.getAvailableModels(), + stateModel: (await probe.getState()).model, + }))); + } catch { + throw new AppError('Pi 未认证', { code: 'PI_NOT_AUTHENTICATED' }); + } + + if (rows.length === 0) { + throw new AppError('Pi 未认证', { code: 'PI_NOT_AUTHENTICATED' }); + } + + const options = rows.map(mapModel); + const defaultValue = + (stateModel && options.find((option) => option.value === stateModel)?.value) ?? + options[0].value; + + return { + OPTIONS: options, + DEFAULT: defaultValue, + }; + } + + async getCurrentActiveModel(_sessionId?: string): Promise { + return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels()); + } +} diff --git a/server/modules/providers/list/pi/pi-paths.provider.test.ts b/server/modules/providers/list/pi/pi-paths.provider.test.ts new file mode 100644 index 0000000000..0c3c74143b --- /dev/null +++ b/server/modules/providers/list/pi/pi-paths.provider.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { PiPaths } from '@/modules/providers/list/pi/pi-paths.provider.js'; + +const PI_ENV_KEYS = [ + 'PI_CLI_PATH', + 'PI_CODING_AGENT_DIR', + 'PI_CODING_AGENT_SESSION_DIR', + 'PI_RPC_CLI_ENTRY', +] as const; + +function withEnv(overrides: Record, run: () => void): void { + const saved = new Map(); + for (const key of PI_ENV_KEYS) { + saved.set(key, process.env[key]); + delete process.env[key]; + } + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + run(); + } finally { + for (const [key, value] of saved) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'pi-paths-')); +} + +test('getCliPath defaults to "pi" when PI_CLI_PATH is unset', () => { + withEnv({}, () => { + assert.equal(new PiPaths().getCliPath(), 'pi'); + }); +}); + +test('getCliPath resolves PI_CLI_PATH', () => { + withEnv({ PI_CLI_PATH: '/opt/tools/../tools/pi' }, () => { + assert.equal(new PiPaths().getCliPath(), path.resolve('/opt/tools/pi')); + }); +}); + +test('getRpcCliEntry resolves the pi package dist/cli.js entry', () => { + withEnv({}, () => { + const entry = new PiPaths().getRpcCliEntry(); + assert.ok(entry.endsWith('cli.js'), `expected a cli.js path, got ${entry}`); + assert.ok(path.isAbsolute(entry), `expected an absolute path, got ${entry}`); + assert.ok(fs.existsSync(entry), `expected ${entry} to exist on disk`); + }); +}); + +test('getRpcCliEntry honours PI_RPC_CLI_ENTRY when it points to a .js file', () => { + const dir = makeTempDir(); + try { + const entry = path.join(dir, 'my-cli.js'); + fs.writeFileSync(entry, ''); + withEnv({ PI_RPC_CLI_ENTRY: entry }, () => { + assert.equal(new PiPaths().getRpcCliEntry(), path.resolve(entry)); + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getAgentDir defaults to ~/.pi/agent', () => { + withEnv({}, () => { + assert.equal( + new PiPaths().getAgentDir(), + path.resolve(path.join(os.homedir(), '.pi', 'agent')), + ); + }); +}); + +test('getAgentDir honours PI_CODING_AGENT_DIR', () => { + const dir = makeTempDir(); + try { + withEnv({ PI_CODING_AGENT_DIR: path.join(dir, 'sub', '..', 'sub') }, () => { + assert.equal(new PiPaths().getAgentDir(), path.resolve(path.join(dir, 'sub'))); + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getSessionRoots defaults to /sessions', () => { + const dir = makeTempDir(); + try { + withEnv({ PI_CODING_AGENT_DIR: dir }, () => { + assert.deepEqual(new PiPaths().getSessionRoots(), [ + path.resolve(path.join(dir, 'sessions')), + ]); + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getSessionRoots uses settings.json sessionDir over the default', () => { + const dir = makeTempDir(); + try { + const sessionDir = path.join(dir, 'custom-sessions'); + fs.writeFileSync( + path.join(dir, 'settings.json'), + JSON.stringify({ sessionDir }), + ); + withEnv({ PI_CODING_AGENT_DIR: dir }, () => { + assert.deepEqual(new PiPaths().getSessionRoots(), [path.resolve(sessionDir)]); + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getSessionRoots prefers PI_CODING_AGENT_SESSION_DIR over settings.json', () => { + const dir = makeTempDir(); + try { + const settingsDir = path.join(dir, 'from-settings'); + fs.writeFileSync( + path.join(dir, 'settings.json'), + JSON.stringify({ sessionDir: settingsDir }), + ); + const envDir = path.join(dir, 'from-env'); + withEnv( + { PI_CODING_AGENT_DIR: dir, PI_CODING_AGENT_SESSION_DIR: envDir }, + () => { + assert.deepEqual(new PiPaths().getSessionRoots(), [path.resolve(envDir)]); + }, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getSessionRoots ignores malformed settings.json and falls back to default', () => { + const dir = makeTempDir(); + try { + fs.writeFileSync(path.join(dir, 'settings.json'), '{ not json'); + withEnv({ PI_CODING_AGENT_DIR: dir }, () => { + assert.deepEqual(new PiPaths().getSessionRoots(), [ + path.resolve(path.join(dir, 'sessions')), + ]); + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/server/modules/providers/list/pi/pi-paths.provider.ts b/server/modules/providers/list/pi/pi-paths.provider.ts new file mode 100644 index 0000000000..057a07b5d3 --- /dev/null +++ b/server/modules/providers/list/pi/pi-paths.provider.ts @@ -0,0 +1,112 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Resolves the on-disk locations Pi uses: the CLI executable, the agent + * directory, and the session root(s). Pure path resolution only - it performs + * no RPC, no file parsing, and no database access. + * + * Precedence for the session root (highest to lowest): + * 1. `PI_CODING_AGENT_SESSION_DIR` environment variable + * 2. `sessionDir` field in `/settings.json` + * 3. Default `/sessions` + */ +export class PiPaths { + /** + * Path to the Pi executable. Defaults to the bare `pi` command so it is + * resolved via PATH; an explicit `PI_CLI_PATH` is resolved to an absolute + * path. + */ + getCliPath(): string { + const configured = process.env.PI_CLI_PATH; + if (!configured) { + return 'pi'; + } + return normalizePath(configured); + } + + /** + * Absolute path to the Pi package's JS entry (`dist/cli.js`) used to spawn + * the RPC subprocess. The official `RpcClient` runs `node ...`, so + * this MUST be an executable JS file - the bare `pi` command from + * {@link getCliPath} would make node treat `pi` as a local module and crash. + * + * Resolution order: + * 1. `PI_RPC_CLI_ENTRY` env, if it points to a `.js` file. + * 2. The installed `@earendil-works/pi-coding-agent` package: resolve its + * top-level entry (`dist/index.js`) and join `cli.js` in the same dir. + * The package's `exports` block hides `package.json` and `./cli.js`, so we + * resolve the exported `.` entry (which succeeds) and derive the sibling. + */ + getRpcCliEntry(): string { + const configured = process.env.PI_RPC_CLI_ENTRY; + if (configured && configured.endsWith('.js')) { + return normalizePath(configured); + } + + const mainEntry = fileURLToPath( + import.meta.resolve('@earendil-works/pi-coding-agent'), + ); + return path.join(path.dirname(mainEntry), 'cli.js'); + } + + /** + * The Pi agent directory. Defaults to `~/.pi/agent`, overridable via + * `PI_CODING_AGENT_DIR`. + */ + getAgentDir(): string { + const configured = process.env.PI_CODING_AGENT_DIR; + if (configured) { + return normalizePath(configured); + } + return normalizePath(path.join(os.homedir(), '.pi', 'agent')); + } + + /** + * The session root directories, deduplicated and resolved. + */ + getSessionRoots(): string[] { + const roots: string[] = []; + + const envSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR; + if (envSessionDir) { + roots.push(normalizePath(envSessionDir)); + } else { + const settingsSessionDir = this.readSettingsSessionDir(); + if (settingsSessionDir) { + roots.push(normalizePath(settingsSessionDir)); + } else { + roots.push(normalizePath(path.join(this.getAgentDir(), 'sessions'))); + } + } + + return [...new Set(roots)]; + } + + private readSettingsSessionDir(): string | undefined { + const settingsPath = path.join(this.getAgentDir(), 'settings.json'); + let raw: string; + try { + raw = fs.readFileSync(settingsPath, 'utf8'); + } catch { + return undefined; + } + + try { + const parsed = JSON.parse(raw) as { sessionDir?: unknown }; + if (typeof parsed.sessionDir === 'string' && parsed.sessionDir.length > 0) { + return parsed.sessionDir; + } + } catch { + return undefined; + } + + return undefined; + } +} + +function normalizePath(input: string): string { + return path.normalize(path.resolve(input)); +} diff --git a/server/modules/providers/list/pi/pi-rpc-client.provider.test.ts b/server/modules/providers/list/pi/pi-rpc-client.provider.test.ts new file mode 100644 index 0000000000..5532034b8b --- /dev/null +++ b/server/modules/providers/list/pi/pi-rpc-client.provider.test.ts @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PiRpcClient } from './pi-rpc-client.provider.js'; + +type Listener = (event: unknown) => void; + +class FakeUnderlyingClient { + startCalls = 0; + stopCalls = 0; + abortCalls = 0; + stderr = ''; + stopResolves = true; + state: unknown = { status: 'idle' }; + models: unknown[] = [{ id: 'm1' }]; + commands: unknown[] = [{ name: '/help' }]; + // When set, request-style methods reject with this error (simulates the + // official client rejecting pending requests on unexpected process exit). + requestError: Error | null = null; + private listeners = new Set(); + private closeListeners = new Set<() => void>(); + + async start(): Promise { + this.startCalls += 1; + } + + async stop(): Promise { + this.stopCalls += 1; + if (!this.stopResolves) { + await new Promise(() => {}); + } + } + + onEvent(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + getStderr(): string { + return this.stderr; + } + + prompt(): Promise { + return Promise.resolve(); + } + + abort(): Promise { + this.abortCalls += 1; + return Promise.resolve(); + } + + getState(): Promise { + if (this.requestError) return Promise.reject(this.requestError); + return Promise.resolve(this.state); + } + + getAvailableModels(): Promise { + if (this.requestError) return Promise.reject(this.requestError); + return Promise.resolve(this.models); + } + + getCommands(): Promise { + if (this.requestError) return Promise.reject(this.requestError); + return Promise.resolve(this.commands); + } + + onClose(listener: () => void): () => void { + this.closeListeners.add(listener); + return () => this.closeListeners.delete(listener); + } + + emit(event: unknown): void { + for (const listener of this.listeners) listener(event); + } + + emitClose(): void { + for (const listener of this.closeListeners) listener(); + } +} + +const makeClient = (fake: FakeUnderlyingClient, options: Record = {}) => { + const captured: { options?: Record } = {}; + const client = new PiRpcClient(options, { + createClient: (resolvedOptions) => { + captured.options = resolvedOptions as Record; + return fake as unknown as never; + }, + }); + return { client, captured }; +}; + +test('start injects --no-extensions and merges caller options', async () => { + const fake = new FakeUnderlyingClient(); + const { client, captured } = makeClient(fake, { cwd: '/tmp/work', env: { FOO: 'bar' } }); + + await client.start(); + + assert.equal(fake.startCalls, 1); + const args = (captured.options?.args as string[]) ?? []; + assert.deepEqual(args, ['--no-extensions']); + assert.equal(captured.options?.cwd, '/tmp/work'); + assert.deepEqual(captured.options?.env, { FOO: 'bar' }); +}); + +test('start merges caller args after the fixed args', async () => { + const fake = new FakeUnderlyingClient(); + const { client, captured } = makeClient(fake, { args: ['--extra'] }); + + await client.start(); + + assert.deepEqual(captured.options?.args, ['--no-extensions', '--extra']); +}); + +test('onEvent forwards events in dispatch order', async () => { + const fake = new FakeUnderlyingClient(); + const { client } = makeClient(fake); + await client.start(); + + const received: unknown[] = []; + const unsubscribe = client.onEvent((event) => received.push(event)); + + fake.emit({ type: 'a' }); + fake.emit({ type: 'b' }); + fake.emit({ type: 'c' }); + unsubscribe(); + fake.emit({ type: 'd' }); + + assert.deepEqual(received, [{ type: 'a' }, { type: 'b' }, { type: 'c' }]); +}); + +test('getStderr passes through underlying stderr without polluting events', async () => { + const fake = new FakeUnderlyingClient(); + const { client } = makeClient(fake); + await client.start(); + + const received: unknown[] = []; + client.onEvent((event) => received.push(event)); + + fake.stderr = 'boom on stderr\n'; + fake.emit({ type: 'agent_settled' }); + + assert.equal(client.getStderr(), 'boom on stderr\n'); + assert.deepEqual(received, [{ type: 'agent_settled' }]); +}); + +test('prompt forwards to the underlying client and resolves immediately', async () => { + const fake = new FakeUnderlyingClient(); + const { client } = makeClient(fake); + await client.start(); + + await client.prompt('hi'); +}); + +test('abort forwards to the underlying client', async () => { + const fake = new FakeUnderlyingClient(); + const { client } = makeClient(fake); + await client.start(); + + await client.abort(); + assert.equal(fake.abortCalls, 1); +}); + +test('getState/getAvailableModels/getCommands forward underlying results', async () => { + const fake = new FakeUnderlyingClient(); + const { client } = makeClient(fake); + await client.start(); + + assert.deepEqual(await client.getState(), { status: 'idle' }); + assert.deepEqual(await client.getAvailableModels(), [{ id: 'm1' }]); + assert.deepEqual(await client.getCommands(), [{ name: '/help' }]); +}); + +test('request methods transparently propagate underlying rejection on process exit', async () => { + const fake = new FakeUnderlyingClient(); + const { client } = makeClient(fake); + await client.start(); + + const exitError = new Error('process exited unexpectedly'); + fake.requestError = exitError; + + await assert.rejects(client.getState(), (err: Error) => err === exitError); + await assert.rejects(client.getAvailableModels(), (err: Error) => err === exitError); + await assert.rejects(client.getCommands(), (err: Error) => err === exitError); +}); + +test('close forces resolution after graceful timeout when stop hangs', async () => { + const fake = new FakeUnderlyingClient(); + fake.stopResolves = false; + const { client } = makeClient(fake); + await client.start(); + + await client.close(20); + assert.equal(fake.stopCalls, 1); +}); + +test('close resolves promptly when underlying stop resolves', async () => { + const fake = new FakeUnderlyingClient(); + const { client } = makeClient(fake); + await client.start(); + + await client.close(1000); + assert.equal(fake.stopCalls, 1); +}); + +test('onClose fires when the underlying process exits', async () => { + const fake = new FakeUnderlyingClient(); + const { client } = makeClient(fake); + await client.start(); + + let closed = 0; + const unsubscribe = client.onClose(() => { + closed += 1; + }); + + fake.emitClose(); + assert.equal(closed, 1); + + unsubscribe(); + fake.emitClose(); + assert.equal(closed, 1); +}); + +// Real-spawn integration test for the original failure: PiRpcClient must spawn +// `node `, not `node pi`. An empty array is valid when CI +// has no Pi credentials; receiving the RPC response is the pass condition. +test('real spawn: default PiRpcClient resolves cli.js and receives an RPC response', async () => { + const client = new PiRpcClient({}); + try { + await client.start(); + const models = await client.getAvailableModels(); + assert.ok(Array.isArray(models)); + } finally { + await client.close(2000); + } +}); diff --git a/server/modules/providers/list/pi/pi-rpc-client.provider.ts b/server/modules/providers/list/pi/pi-rpc-client.provider.ts new file mode 100644 index 0000000000..0e7ec6f6e5 --- /dev/null +++ b/server/modules/providers/list/pi/pi-rpc-client.provider.ts @@ -0,0 +1,188 @@ +/** + * PiRpcClient - thin wrapper around the official Pi rpc-client. + * + * Spawns Pi in RPC mode with extensions disabled (via the official RpcClient) and adds + * a small layer the runtime relies on: + * - fixed additional args injection (--no-extensions), merged with caller args, + * - event dispatch fan-out (onEvent), + * - stderr pass-through (getStderr), + * - graceful close with a bounded window before giving up. + * + * Request/response correlation, timeouts and rejection of pending requests on + * unexpected process exit are handled by the official client; this wrapper only + * forwards to it and does not re-implement that machinery. + */ +import type { ChildProcess } from 'node:child_process'; + +import { + RpcClient, + type RpcClientOptions, + type AgentSessionEvent, + type ModelInfo, + type RpcSessionState, +} from '@earendil-works/pi-coding-agent'; + +import { PiPaths } from './pi-paths.provider.js'; + +/** + * Local equivalent of the official `RpcSlashCommand` (returned by + * `getCommands()`). The package does not export this type from its top-level + * entry, so we mirror its shape here to keep type-checking working. + */ +type RpcSlashCommand = { + name: string; + description?: string; + source: 'extension' | 'prompt' | 'skill'; + sourceInfo?: unknown; +}; + +type EventListener = (event: AgentSessionEvent) => void; + +/** + * Minimal surface the wrapper depends on. The default adapter is backed by the + * official {@link RpcClient}; tests inject a stub implementing this shape. + */ +export interface UnderlyingRpcClient { + start(): Promise; + stop(): Promise; + onEvent(listener: EventListener): () => void; + /** + * Subscribes to real process exit. The runtime relies on this to detect a + * close before `agent_settled` (ERR-PI-RUN-FAILED) instead of hanging. + * Optional so lightweight test stubs need not implement it. + */ + onClose?(listener: () => void): () => void; + getStderr(): string; + prompt(message: string, images?: unknown[]): Promise; + abort(): Promise; + getState(): Promise; + getAvailableModels(): Promise; + getCommands(): Promise; +} + +export interface PiRpcClientDeps { + createClient(options: RpcClientOptions): UnderlyingRpcClient; +} + +// RpcClient itself always adds `--mode rpc`; only wrapper-owned flags belong here. +const FIXED_ADDITIONAL_ARGS = ['--no-extensions']; + +/** Default adapter wiring the official RpcClient to {@link UnderlyingRpcClient}. */ +const defaultDeps: PiRpcClientDeps = { + createClient(options) { + const client = new RpcClient(options); + return { + start: () => client.start(), + stop: () => client.stop(), + onEvent: (listener) => client.onEvent(listener), + onClose: (listener) => { + // The official client exposes its spawned ChildProcess as a public + // field once start() has run. Forward its `exit` to the runtime so a + // process death before agent_settled surfaces as a failure. + // `process` is a runtime-public field; the shipped .d.ts marks it + // private, so reach it through a narrow typed view. + const child = (client as unknown as { process: ChildProcess | null }).process; + if (!child) return () => {}; + const onExit = (): void => listener(); + child.once('exit', onExit); + return () => { + child.removeListener('exit', onExit); + }; + }, + getStderr: () => client.getStderr(), + prompt: (message, images) => client.prompt(message, images as never), + abort: () => client.abort(), + getState: () => client.getState(), + getAvailableModels: () => client.getAvailableModels(), + getCommands: () => client.getCommands(), + }; + }, +}; + +export class PiRpcClient { + private readonly deps: PiRpcClientDeps; + private readonly options: RpcClientOptions; + private client: UnderlyingRpcClient | null = null; + + constructor(options: RpcClientOptions = {}, deps: PiRpcClientDeps = defaultDeps) { + this.options = options; + this.deps = deps; + } + + async start(): Promise { + const { args, cliPath, ...rest } = this.options; + const client = this.deps.createClient({ + ...rest, + // The official RpcClient runs `node ...`, so cliPath MUST be a + // JS entry (dist/cli.js), never the bare `pi` command. Default to the + // resolved package entry unless a caller explicitly overrides it. + cliPath: cliPath ?? new PiPaths().getRpcCliEntry(), + args: [...FIXED_ADDITIONAL_ARGS, ...(args ?? [])], + }); + this.client = client; + await client.start(); + } + + onEvent(listener: EventListener): () => void { + return this.requireClient().onEvent(listener); + } + + onClose(listener: () => void): () => void { + const client = this.requireClient(); + return client.onClose ? client.onClose(listener) : () => {}; + } + + getStderr(): string { + return this.client ? this.client.getStderr() : ''; + } + + prompt(message: string, images?: unknown[]): Promise { + return this.requireClient().prompt(message, images); + } + + abort(): Promise { + return this.requireClient().abort(); + } + + getState(): Promise { + return this.requireClient().getState(); + } + + getAvailableModels(): Promise { + return this.requireClient().getAvailableModels(); + } + + getCommands(): Promise { + return this.requireClient().getCommands(); + } + + /** + * Gracefully stop the underlying client. If it does not settle within + * `graceMs`, stop waiting and resolve anyway (the official stop() has already + * signalled the process; this is only a bounded-wait safety net). + */ + async close(graceMs: number): Promise { + const client = this.client; + if (!client) return; + this.client = null; + + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, graceMs); + }); + const stopped = Promise.resolve() + .then(() => client.stop()) + .then( + () => undefined, + () => undefined, + ); + + await Promise.race([stopped, timeout]); + if (timer) clearTimeout(timer); + } + + private requireClient(): UnderlyingRpcClient { + if (!this.client) throw new Error('PiRpcClient not started'); + return this.client; + } +} diff --git a/server/modules/providers/list/pi/pi-runtime.provider.test.ts b/server/modules/providers/list/pi/pi-runtime.provider.test.ts new file mode 100644 index 0000000000..faa428367f --- /dev/null +++ b/server/modules/providers/list/pi/pi-runtime.provider.test.ts @@ -0,0 +1,643 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { RpcClientOptions } from '@earendil-works/pi-coding-agent'; + +import { + createPiRuntime, + mapPiEvent, + isSettledEvent, + type PiRuntimeRpc, +} from './pi-runtime.provider.js'; +import { PiRpcClient } from './pi-rpc-client.provider.js'; + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +/** Stub RPC client that lets tests drive the event/lifecycle timeline. */ +class FakeRpc implements PiRuntimeRpc { + startCalls = 0; + abortCalls = 0; + closeCalls: number[] = []; + promptCalls: string[] = []; + state: { sessionId: string; sessionFile?: string; isStreaming: boolean } = { + sessionId: 'native-1', + isStreaming: false, + }; + startError: Error | null = null; + + private eventListeners = new Set<(e: unknown) => void>(); + private closeListeners = new Set<() => void>(); + + async start(): Promise { + this.startCalls += 1; + if (this.startError) throw this.startError; + } + + onEvent(listener: (e: unknown) => void): () => void { + this.eventListeners.add(listener); + return () => this.eventListeners.delete(listener); + } + + onClose(listener: () => void): () => void { + this.closeListeners.add(listener); + return () => this.closeListeners.delete(listener); + } + + async getState() { + return this.state as never; + } + + async prompt(message: string): Promise { + this.promptCalls.push(message); + } + + async abort(): Promise { + this.abortCalls += 1; + } + + async close(graceMs: number): Promise { + this.closeCalls.push(graceMs); + } + + getStderr(): string { + return ''; + } + + emit(event: unknown): void { + for (const l of [...this.eventListeners]) l(event); + } + + closeProcess(): void { + for (const l of [...this.closeListeners]) l(); + } +} + +interface CapturedMessage { + id?: string; + kind: string; + content?: string; + code?: string; + success?: boolean; + aborted?: boolean; + isStreaming?: boolean; + duration?: number; + timestamp?: string; + newSessionId?: string; + sessionId?: string; + [key: string]: unknown; +} + +function makeWriter() { + const sent: CapturedMessage[] = []; + const sessionIds: string[] = []; + const writer = { + send(data: unknown) { + sent.push(data as CapturedMessage); + }, + setSessionId(id: string) { + sessionIds.push(id); + }, + }; + return { writer, sent, sessionIds }; +} + +function makeContext(mapped: string | null = null) { + return { + resolveProviderSessionId: () => mapped, + resolveResumeModel: async () => undefined, + getProviderModels: async () => ({}) as never, + normalizeMessage: () => [], + isProviderInstalled: async () => true, + }; +} + +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +// --------------------------------------------------------------------------- +// Pure event mapping (T1 stream shape, T3 protocol, T4 unknown) +// --------------------------------------------------------------------------- + +test('mapPiEvent maps text and thinking lifecycle events without losing block identity', () => { + assert.deepEqual( + mapPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'hello' }, + }), + { kind: 'stream_delta', content: 'hello' }, + ); + assert.deepEqual( + mapPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'thinking_start', contentIndex: 2 }, + }), + { kind: 'thinking_start', contentIndex: 2 }, + ); + assert.deepEqual( + mapPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'thinking_delta', contentIndex: 2, delta: 'ponder' }, + }), + { kind: 'thinking_delta', contentIndex: 2, content: 'ponder' }, + ); + assert.deepEqual( + mapPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'thinking_end', contentIndex: 2, content: 'ponder fully' }, + }), + { kind: 'thinking_end', contentIndex: 2, content: 'ponder fully' }, + ); +}); + +test('mapPiEvent maps tool execution start/end and retry/turn_end to status', () => { + assert.deepEqual( + mapPiEvent({ type: 'tool_execution_start', toolCallId: 't1', toolName: 'bash', args: { a: 1 } }), + { kind: 'tool_use', toolId: 't1', toolName: 'bash', toolInput: { a: 1 } }, + ); + assert.deepEqual( + mapPiEvent({ type: 'tool_execution_end', toolCallId: 't1', toolName: 'bash', result: 'ok', isError: false }), + { kind: 'tool_result', toolId: 't1', toolName: 'bash', content: 'ok', isError: false }, + ); + assert.deepEqual( + mapPiEvent({ + type: 'tool_execution_end', + toolCallId: 't2', + toolName: 'read', + result: { + content: [ + { type: 'text', text: 'first line' }, + { type: 'text', text: 'second line' }, + ], + details: { path: 'README.md' }, + }, + isError: false, + }), + { + kind: 'tool_result', + toolId: 't2', + toolName: 'read', + content: 'first line\nsecond line', + isError: false, + }, + ); + assert.deepEqual( + mapPiEvent({ + type: 'tool_execution_end', + toolCallId: 't3', + toolName: 'write', + result: { content: 'nested string result' }, + isError: true, + }), + { + kind: 'tool_result', + toolId: 't3', + toolName: 'write', + content: 'nested string result', + isError: true, + }, + ); + assert.deepEqual( + mapPiEvent({ + type: 'tool_execution_end', + toolCallId: 't4', + toolName: 'read', + result: undefined, + isError: false, + }), + { + kind: 'tool_result', + toolId: 't4', + toolName: 'read', + content: '', + isError: false, + }, + ); + assert.deepEqual( + mapPiEvent({ + type: 'tool_execution_end', + toolCallId: 't5', + toolName: 'calculate', + result: 42n, + isError: false, + }), + { + kind: 'tool_result', + toolId: 't5', + toolName: 'calculate', + content: '42', + isError: false, + }, + ); + assert.deepEqual( + mapPiEvent({ type: 'turn_end', turnIndex: 0 }), + { kind: 'status', status: 'turn_end' }, + ); + assert.deepEqual( + mapPiEvent({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 10, errorMessage: 'x' }), + { kind: 'status', status: 'retry' }, + ); +}); + +// T3: known event, illegal payload → ERR-PI-RPC-PROTOCOL (thrown, not success) +test('T3: mapPiEvent throws ERR-PI-RPC-PROTOCOL on illegal known-event payload', () => { + assert.throws( + () => mapPiEvent({ type: 'message_update', assistantMessageEvent: { type: 'text_delta' } }), + (err: unknown) => (err as { code?: string }).code === 'ERR-PI-RPC-PROTOCOL', + ); + assert.throws( + () => mapPiEvent({ type: 'tool_execution_start', toolName: 'bash' }), + (err: unknown) => (err as { code?: string }).code === 'ERR-PI-RPC-PROTOCOL', + ); +}); + +// T4: unknown event → ignored (null), no throw +test('T4: mapPiEvent returns null for unknown events', () => { + assert.equal(mapPiEvent({ type: 'totally_unknown' }), null); + assert.equal(mapPiEvent({ type: 'queue_update', steering: [], followUp: [] }), null); + assert.equal(mapPiEvent({ notAnEvent: true }), null); +}); + +test('isSettledEvent detects only agent_settled', () => { + assert.equal(isSettledEvent({ type: 'agent_settled' }), true); + assert.equal(isSettledEvent({ type: 'agent_end', messages: [], willRetry: false }), false); + assert.equal(isSettledEvent({ type: 'turn_end' }), false); +}); + +// --------------------------------------------------------------------------- +// Runtime state machine +// --------------------------------------------------------------------------- + +// T1: normal stream → normalized text/thinking + one success complete +test('T1: streams normalized text/thinking then completes once on agent_settled', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake }); + const { writer, sent } = makeWriter(); + + const runPromise = runtime.run('hi', { sessionId: 'app-1' }, writer, makeContext()); + await tick(); + + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'Hel' } }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_start', contentIndex: 0 } }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', contentIndex: 0, delta: 'hmm' } }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_end', contentIndex: 0, content: 'hmm' } }); + fake.emit({ type: 'agent_settled' }); + + const outcome = await runPromise; + assert.equal(outcome.status, 'settled'); + + const streamDeltas = sent.filter((m) => m.kind === 'stream_delta'); + const thinking = sent.filter((m) => m.kind === 'thinking'); + const completes = sent.filter((m) => m.kind === 'complete'); + assert.equal(streamDeltas.length, 1); + assert.equal(streamDeltas[0].content, 'Hel'); + assert.equal(new Set(thinking.map((message) => message.id)).size, 1); + assert.equal(thinking.at(-1)?.content, 'hmm'); + assert.equal(thinking.at(-1)?.isStreaming, false); + assert.equal(completes.length, 1); + assert.equal(completes[0].success, true); +}); + +test('Pi thinking deltas update one stable logical message and finish with authoritative content', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake, thinkingFlushMs: 0 }); + const { writer, sent } = makeWriter(); + + const runPromise = runtime.run('hi', { sessionId: 'app-1' }, writer, makeContext()); + await tick(); + + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_start', contentIndex: 0 } }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', contentIndex: 0, delta: 'The' } }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', contentIndex: 0, delta: ' answer' } }); + fake.emit({ + type: 'message_update', + assistantMessageEvent: { type: 'thinking_end', contentIndex: 0, content: 'The authoritative answer' }, + }); + fake.emit({ type: 'agent_settled' }); + + await runPromise; + + const thinking = sent.filter((message) => message.kind === 'thinking'); + assert.deepEqual(thinking.map((message) => message.content), [ + 'The', + 'The answer', + 'The authoritative answer', + ]); + assert.equal(new Set(thinking.map((message) => message.id)).size, 1); + assert.equal(new Set(thinking.map((message) => message.timestamp)).size, 1); + assert.deepEqual(thinking.map((message) => message.isStreaming), [true, true, false]); + assert.equal(typeof thinking.at(-1)?.duration, 'number'); +}); + +test('agent settlement finalizes a thinking block even when Pi omits thinking_end', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake, thinkingFlushMs: 0 }); + const { writer, sent } = makeWriter(); + + const runPromise = runtime.run('hi', { sessionId: 'app-1' }, writer, makeContext()); + await tick(); + + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_start', contentIndex: 0 } }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', contentIndex: 0, delta: 'unfinished' } }); + fake.emit({ type: 'agent_settled' }); + + await runPromise; + + const thinking = sent.filter((message) => message.kind === 'thinking'); + assert.equal(thinking.at(-1)?.content, 'unfinished'); + assert.equal(thinking.at(-1)?.isStreaming, false); + assert.ok(sent.findIndex((message) => message.kind === 'thinking' && message.isStreaming === false) + < sent.findIndex((message) => message.kind === 'complete')); +}); + +test('separate Pi thinking lifecycles keep distinct logical message ids', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake, thinkingFlushMs: 0 }); + const { writer, sent } = makeWriter(); + + const runPromise = runtime.run('hi', { sessionId: 'app-1' }, writer, makeContext()); + await tick(); + + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_start', contentIndex: 0 } }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', contentIndex: 0, delta: 'first' } }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_end', contentIndex: 0, content: 'first' } }); + fake.emit({ type: 'tool_execution_start', toolCallId: 'tool-1', toolName: 'read', args: {} }); + fake.emit({ type: 'tool_execution_end', toolCallId: 'tool-1', toolName: 'read', result: 'ok', isError: false }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_start', contentIndex: 0 } }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', contentIndex: 0, delta: 'second' } }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_end', contentIndex: 0, content: 'second' } }); + fake.emit({ type: 'agent_settled' }); + + await runPromise; + + const finalizedThinking = sent.filter( + (message) => message.kind === 'thinking' && message.isStreaming === false, + ); + assert.deepEqual(finalizedThinking.map((message) => message.content), ['first', 'second']); + assert.equal(new Set(finalizedThinking.map((message) => message.id)).size, 2); +}); + +test('runtime forwards the selected model, existing native session, and thinking level', async () => { + const fake = new FakeRpc(); + let capturedOptions: RpcClientOptions | undefined; + const runtime = createPiRuntime({ + createRpcClient: (options) => { + capturedOptions = options; + return fake; + }, + }); + const { writer } = makeWriter(); + + const runPromise = runtime.run( + 'hi', + { + sessionId: 'app-1', + cwd: '/tmp/pi-project', + model: 'tcredit/deepseek-r1', + effort: 'high', + }, + writer, + makeContext('native-existing'), + ); + await tick(); + + assert.deepEqual(capturedOptions, { + cwd: '/tmp/pi-project', + provider: 'tcredit', + model: 'deepseek-r1', + args: ['--session-id', 'native-existing', '--thinking', 'high'], + }); + + fake.emit({ type: 'agent_settled' }); + await runPromise; +}); + +test('runtime closes the RPC subprocess after a settled run', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake }); + const { writer } = makeWriter(); + + const runPromise = runtime.run('hi', { sessionId: 'app-1' }, writer, makeContext()); + await tick(); + fake.emit({ type: 'agent_settled' }); + await runPromise; + + assert.equal(fake.closeCalls.length, 1); +}); + +// T2: process close before agent_settled → ERR-PI-RUN-FAILED failure complete +test('T2: process close before settle fails with ERR-PI-RUN-FAILED', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake }); + const { writer, sent } = makeWriter(); + + const runPromise = runtime.run('hi', { sessionId: 'app-1' }, writer, makeContext()); + await tick(); + + fake.closeProcess(); + + const outcome = await runPromise; + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.errorCode, 'ERR-PI-RUN-FAILED'); + + const errors = sent.filter((m) => m.kind === 'error'); + const completes = sent.filter((m) => m.kind === 'complete'); + assert.equal(errors.length, 1); + assert.equal(errors[0].code, 'ERR-PI-RUN-FAILED'); + assert.equal(completes.length, 1); + assert.equal(completes[0].success, false); +}); + +// T3 (runtime side): illegal known-event payload → ERR-PI-RPC-PROTOCOL failure +test('T3: runtime fails with ERR-PI-RPC-PROTOCOL on illegal known-event payload', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake }); + const { writer, sent } = makeWriter(); + + const runPromise = runtime.run('hi', { sessionId: 'app-1' }, writer, makeContext()); + await tick(); + + fake.emit({ type: 'tool_execution_start', toolName: 'bash' }); + + const outcome = await runPromise; + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.errorCode, 'ERR-PI-RPC-PROTOCOL'); + assert.equal(sent.filter((m) => m.kind === 'error')[0].code, 'ERR-PI-RPC-PROTOCOL'); +}); + +// T4: unknown event ignored, run continues to success +test('T4: unknown event is ignored and does not affect the run', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake }); + const { writer, sent } = makeWriter(); + + const runPromise = runtime.run('hi', { sessionId: 'app-1' }, writer, makeContext()); + await tick(); + + fake.emit({ type: 'totally_unknown', foo: 1 }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'ok' } }); + fake.emit({ type: 'agent_settled' }); + + const outcome = await runPromise; + assert.equal(outcome.status, 'settled'); + assert.equal(sent.filter((m) => m.kind === 'stream_delta').length, 1); +}); + +// T5: binding happens before the first live event +test('T5: persists app/native binding before the first live event', async () => { + const fake = new FakeRpc(); + fake.state = { sessionId: 'native-xyz', isStreaming: false }; + const runtime = createPiRuntime({ createRpcClient: () => fake }); + const { writer, sent, sessionIds } = makeWriter(); + + // context returns null → app session not yet mapped → fresh binding expected + const runPromise = runtime.run('hi', { sessionId: 'app-1' }, writer, makeContext(null)); + await tick(); + + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'x' } }); + fake.emit({ type: 'agent_settled' }); + await runPromise; + + const bindIdx = sent.findIndex((m) => m.kind === 'session_created'); + const firstLiveIdx = sent.findIndex((m) => m.kind === 'stream_delta'); + assert.ok(bindIdx >= 0, 'binding event emitted'); + assert.ok(bindIdx < firstLiveIdx, 'binding precedes first live event'); + assert.equal(sent[bindIdx].newSessionId, 'native-xyz'); + assert.deepEqual(sessionIds, ['native-xyz']); +}); + +// --------------------------------------------------------------------------- +// Abort (T7, T8, T9) +// --------------------------------------------------------------------------- + +// T7: abort a streaming run via request.signal → one aborted complete +test('T7: abort during stream produces exactly one aborted complete', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake, abortGraceMs: 5000 }); + const { writer, sent } = makeWriter(); + const controller = new AbortController(); + + const runPromise = runtime.run( + 'hi', + { sessionId: 'app-1', signal: controller.signal }, + writer, + makeContext(), + ); + await tick(); + + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'x' } }); + controller.abort(); + await tick(); + // Pi responds to abort with agent_settled inside the grace window. + fake.emit({ type: 'agent_settled' }); + + const outcome = await runPromise; + assert.equal(outcome.status, 'aborted'); + assert.equal(fake.abortCalls, 1); + const completes = sent.filter((m) => m.kind === 'complete'); + assert.equal(completes.length, 1); + assert.equal(completes[0].aborted, true); + assert.equal(completes[0].success, false); +}); + +// T8: late native event after abort → still only one terminal outcome +test('T8: late native event after abort yields a single terminal', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake, abortGraceMs: 5000 }); + const { writer, sent } = makeWriter(); + const controller = new AbortController(); + + const runPromise = runtime.run( + 'hi', + { sessionId: 'app-1', signal: controller.signal }, + writer, + makeContext(), + ); + await tick(); + + controller.abort(); + await tick(); + fake.emit({ type: 'agent_settled' }); + await runPromise; + + // Pi keeps emitting after the terminal; these must be ignored. + fake.emit({ type: 'agent_settled' }); + fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'late' } }); + await tick(); + + const completes = sent.filter((m) => m.kind === 'complete'); + assert.equal(completes.length, 1); + assert.equal(completes[0].aborted, true); + assert.equal(sent.filter((m) => m.content === 'late').length, 0); +}); + +// T9: grace window elapses with no response → force-kill + aborted complete +test('T9: force-kills after grace window and still completes as aborted', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake, abortGraceMs: 10 }); + const { writer, sent } = makeWriter(); + const controller = new AbortController(); + + const runPromise = runtime.run( + 'hi', + { sessionId: 'app-1', signal: controller.signal }, + writer, + makeContext(), + ); + await tick(); + + controller.abort(); + // No agent_settled ever arrives; the bounded window fires close(0). + const outcome = await runPromise; + assert.equal(outcome.status, 'aborted'); + assert.equal(fake.abortCalls, 1); + assert.deepEqual(fake.closeCalls, [0]); + const completes = sent.filter((m) => m.kind === 'complete'); + assert.equal(completes.length, 1); + assert.equal(completes[0].aborted, true); +}); + +// abort() by app session id targets runs owned by that session only +test('abort(sessionId) aborts the matching run', async () => { + const fake = new FakeRpc(); + const runtime = createPiRuntime({ createRpcClient: () => fake, abortGraceMs: 10 }); + const { writer } = makeWriter(); + + const runPromise = runtime.run('hi', { sessionId: 'app-1' }, writer, makeContext()); + await tick(); + + assert.equal(runtime.abort('other-session'), false); + assert.equal(runtime.abort('app-1'), true); + + const outcome = await runPromise; + assert.equal(outcome.status, 'aborted'); +}); + +// --------------------------------------------------------------------------- +// T28: runtime spawn flags include --no-extensions (matches probe) +// --------------------------------------------------------------------------- + +test('T28: default RPC client spawns with --no-extensions', () => { + let captured: string[] = []; + const client = new PiRpcClient( + {}, + { + createClient: (options) => { + captured = (options.args as string[]) ?? []; + return { + start: async () => {}, + stop: async () => {}, + onEvent: () => () => {}, + getStderr: () => '', + prompt: async () => {}, + abort: async () => {}, + getState: async () => ({}) as never, + getAvailableModels: async () => [], + getCommands: async () => [], + }; + }, + }, + ); + void client.start(); + assert.ok(captured.includes('--no-extensions'), 'runtime spawn flags include --no-extensions'); +}); diff --git a/server/modules/providers/list/pi/pi-runtime.provider.ts b/server/modules/providers/list/pi/pi-runtime.provider.ts new file mode 100644 index 0000000000..1f2e83316d --- /dev/null +++ b/server/modules/providers/list/pi/pi-runtime.provider.ts @@ -0,0 +1,650 @@ +/** + * Pi runtime - live turn execution over the official RPC client. + * + * Responsibilities (see design.md decision 1/2, spec: live chat / 身份绑定 / 中止): + * - Drive one turn through the state machine + * SPAWNING → REQUESTING_STATE → BINDING_SESSION → PROMPTING → STREAMING → SETTLED. + * - Bind the app/native session (from `get_state`) BEFORE the first live event. + * - Map native `AgentSessionEvent`s to normalized events via a pure function. + * - Treat `agent_settled` as the only success terminal; a close before it fails + * with `ERR-PI-RUN-FAILED`; an illegal payload on a known event fails with + * `ERR-PI-RPC-PROTOCOL`; unknown events are ignored (debug-logged). + * - Support abort: send `{type:'abort'}` (client.abort()), wait a bounded grace + * window for `agent_settled`, then force-kill and settle as aborted. Process + * ownership is tracked by runId (not sessionId) so aborting one run never + * touches another run of the same session. + */ +import { randomUUID } from 'node:crypto'; + +import type { RpcClientOptions, RpcSessionState } from '@earendil-works/pi-coding-agent'; + +import { createCompleteMessage, createNormalizedMessage, AppError } from '@/shared/utils.js'; +import type { + AnyRecord, + ProviderRuntimeContext, + ProviderRuntimeWriter, +} from '@/shared/types.js'; + +import { PiRpcClient, type PiRpcClientDeps } from './pi-rpc-client.provider.js'; + +/** Bounded graceful-abort window before the process is force-killed. */ +const DEFAULT_ABORT_GRACE_MS = 5000; +const RUN_CLOSE_GRACE_MS = 1000; +const DEFAULT_THINKING_FLUSH_MS = 100; + +/** Runtime states (progress markers; terminal handling is guarded separately). */ +export type PiRuntimeState = + | 'SPAWNING' + | 'REQUESTING_STATE' + | 'BINDING_SESSION' + | 'PROMPTING' + | 'STREAMING' + | 'SETTLED'; + +/** + * Provider-local event produced by {@link mapPiEvent} and consumed by the Pi + * runtime. Thinking lifecycle variants stay private to this adapter; the + * runtime turns them into stable `kind: "thinking"` message snapshots before + * anything crosses the provider boundary. + */ +export type NormalizedPiEvent = + | { kind: 'stream_delta'; content: string } + | { kind: 'thinking_start'; contentIndex: number } + | { kind: 'thinking_delta'; contentIndex: number; content: string } + | { kind: 'thinking_end'; contentIndex: number; content: string } + | { kind: 'tool_use'; toolId: string; toolName: string; toolInput: unknown } + | { kind: 'tool_result'; toolId: string; toolName: string; content: string; isError: boolean } + | { kind: 'status'; status: string }; + +type ActiveThinkingBlock = { + id: string; + contentIndex: number; + content: string; + lastSentContent: string; + startedAtMs: number; + timestamp: string; + flushTimer?: NodeJS.Timeout; +}; + +/** Minimal RPC surface the runtime depends on (satisfied by {@link PiRpcClient}). */ +export interface PiRuntimeRpc { + start(): Promise; + onEvent(listener: (event: unknown) => void): () => void; + getState(): Promise; + prompt(message: string, images?: unknown[]): Promise; + abort(): Promise; + close(graceMs: number): Promise; + getStderr(): string; + /** + * Optional notification that the underlying process exited. When present the + * runtime uses it to detect a close before `agent_settled` (ERR-PI-RUN-FAILED). + */ + onClose?(listener: () => void): () => void; +} + +/** Factory seam so tests inject a stub RPC client instead of spawning `pi`. */ +export type CreatePiRuntimeRpc = ( + options: RpcClientOptions, + deps?: PiRpcClientDeps, +) => PiRuntimeRpc; + +/** Default factory: the real {@link PiRpcClient} (spawns `pi --mode rpc --no-extensions`). */ +export const defaultCreatePiRuntimeRpc: CreatePiRuntimeRpc = (options, deps) => + new PiRpcClient(options, deps); + +/** Terminal outcome of a single run. */ +export interface PiRunOutcome { + status: 'settled' | 'failed' | 'aborted'; + sessionId: string | null; + errorCode?: 'ERR-PI-RUN-FAILED' | 'ERR-PI-RPC-PROTOCOL'; +} + +interface ActiveRun { + runId: string; + sessionId: string | null; + abort(): void; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function protocolError(message: string): AppError { + return new AppError(message, { code: 'ERR-PI-RPC-PROTOCOL' }); +} + +function formatPiToolResultContent(result: unknown): string { + if (typeof result === 'string') { + return result; + } + + if (isRecord(result)) { + if (typeof result.content === 'string') { + return result.content; + } + + if (Array.isArray(result.content)) { + const textBlocks = result.content.flatMap((block) => + isRecord(block) && block.type === 'text' && typeof block.text === 'string' + ? [block.text] + : [], + ); + if (textBlocks.length > 0) { + return textBlocks.join('\n'); + } + } + } + + if (result === undefined) { + return ''; + } + + try { + return JSON.stringify(result) ?? ''; + } catch { + try { + return String(result); + } catch { + return ''; + } + } +} + +/** + * Pure mapping from a native `AgentSessionEvent` to a normalized event. + * + * Returns `null` for events we intentionally ignore (unknown types, lifecycle + * markers with no UI payload). Throws `ERR-PI-RPC-PROTOCOL` when a KNOWN event + * carries an illegal payload — never silently treats it as success data. + * `agent_settled` is a terminal signal, not a normalized event; callers detect + * it with {@link isSettledEvent} before calling this. + */ +export function mapPiEvent(event: unknown): NormalizedPiEvent | null { + if (!isRecord(event) || typeof event.type !== 'string') { + return null; + } + + switch (event.type) { + case 'message_update': { + const inner = event.assistantMessageEvent; + if (!isRecord(inner) || typeof inner.type !== 'string') { + throw protocolError('message_update missing assistantMessageEvent'); + } + if (inner.type === 'text_delta') { + if (typeof inner.delta !== 'string') { + throw protocolError('text_delta missing string delta'); + } + return { kind: 'stream_delta', content: inner.delta }; + } + if (inner.type === 'thinking_start') { + if (!Number.isInteger(inner.contentIndex) || (inner.contentIndex as number) < 0) { + throw protocolError('thinking_start missing valid contentIndex'); + } + return { kind: 'thinking_start', contentIndex: inner.contentIndex as number }; + } + if (inner.type === 'thinking_delta') { + if ( + !Number.isInteger(inner.contentIndex) + || (inner.contentIndex as number) < 0 + || typeof inner.delta !== 'string' + ) { + throw protocolError('thinking_delta missing valid contentIndex/string delta'); + } + return { + kind: 'thinking_delta', + contentIndex: inner.contentIndex as number, + content: inner.delta, + }; + } + if (inner.type === 'thinking_end') { + if ( + !Number.isInteger(inner.contentIndex) + || (inner.contentIndex as number) < 0 + || typeof inner.content !== 'string' + ) { + throw protocolError('thinking_end missing valid contentIndex/string content'); + } + return { + kind: 'thinking_end', + contentIndex: inner.contentIndex as number, + content: inner.content, + }; + } + return null; + } + + case 'tool_execution_start': { + if (typeof event.toolCallId !== 'string' || typeof event.toolName !== 'string') { + throw protocolError('tool_execution_start missing toolCallId/toolName'); + } + return { + kind: 'tool_use', + toolId: event.toolCallId, + toolName: event.toolName, + toolInput: event.args, + }; + } + + case 'tool_execution_end': { + if (typeof event.toolCallId !== 'string' || typeof event.toolName !== 'string') { + throw protocolError('tool_execution_end missing toolCallId/toolName'); + } + const isError = Boolean(event.isError); + return { + kind: 'tool_result', + toolId: event.toolCallId, + toolName: event.toolName, + content: formatPiToolResultContent(event.result), + isError, + }; + } + + case 'turn_end': + return { kind: 'status', status: 'turn_end' }; + + case 'auto_retry_start': { + if (typeof event.attempt !== 'number') { + throw protocolError('auto_retry_start missing attempt'); + } + return { kind: 'status', status: 'retry' }; + } + + case 'auto_retry_end': + return { kind: 'status', status: 'retry_end' }; + + default: + // Unknown / unmapped lifecycle events are ignored (debug-logged by caller). + return null; + } +} + +/** `agent_settled` is the ONLY success terminal event. */ +export function isSettledEvent(event: unknown): boolean { + return isRecord(event) && event.type === 'agent_settled'; +} + +function buildRpcClientOptions( + options: AnyRecord, + nativeSessionId: string | null, +): RpcClientOptions { + const rpcOptions: RpcClientOptions = { + cwd: typeof options.cwd === 'string' ? options.cwd : undefined, + }; + const model = typeof options.model === 'string' ? options.model.trim() : ''; + const separatorIndex = model.indexOf('/'); + if (separatorIndex > 0 && separatorIndex < model.length - 1) { + rpcOptions.provider = model.slice(0, separatorIndex); + rpcOptions.model = model.slice(separatorIndex + 1); + } else if (model) { + rpcOptions.model = model; + } + + const args: string[] = []; + if (nativeSessionId) { + args.push('--session-id', nativeSessionId); + } + const effort = typeof options.effort === 'string' ? options.effort.trim() : ''; + if (effort && effort !== 'default') { + args.push('--thinking', effort); + } + if (args.length > 0) { + rpcOptions.args = args; + } + + return rpcOptions; +} + +export interface PiRuntimeDeps { + createRpcClient?: CreatePiRuntimeRpc; + abortGraceMs?: number; + /** Snapshot throttle for streamed thinking; tests set zero for determinism. */ + thinkingFlushMs?: number; +} + +/** + * Builds the Pi runtime. `deps` supplies the RPC-client factory (a stub in + * tests) and the abort grace window. + */ +export function createPiRuntime(deps: PiRuntimeDeps = {}) { + const createRpcClient = deps.createRpcClient ?? defaultCreatePiRuntimeRpc; + const abortGraceMs = deps.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS; + const thinkingFlushMs = Math.max(0, deps.thinkingFlushMs ?? DEFAULT_THINKING_FLUSH_MS); + const activeRuns = new Map(); + + async function run( + command: string, + options: AnyRecord, + writer: ProviderRuntimeWriter, + context: ProviderRuntimeContext, + ): Promise { + const runId: string = typeof options.runId === 'string' ? options.runId : randomUUID(); + const appSessionId: string | null = + typeof options.sessionId === 'string' ? options.sessionId : null; + const images = Array.isArray(options.images) ? (options.images as unknown[]) : undefined; + const existingNativeSessionId = context.resolveProviderSessionId(appSessionId); + const requestedNativeSessionId = existingNativeSessionId ?? appSessionId; + + let state: PiRuntimeState = 'SPAWNING'; + let settled = false; + let aborting = false; + let boundSessionId: string | null = appSessionId; + let firstLiveEventSent = false; + const activeThinkingBlocks = new Map(); + + const rpc = createRpcClient(buildRpcClientOptions(options, requestedNativeSessionId)); + + return new Promise((resolve) => { + let abortTimer: NodeJS.Timeout | undefined; + + const createThinkingBlock = (contentIndex: number): ActiveThinkingBlock => { + const existing = activeThinkingBlocks.get(contentIndex); + if (existing) { + return existing; + } + + const startedAtMs = Date.now(); + const block: ActiveThinkingBlock = { + id: `thinking_${randomUUID()}`, + contentIndex, + content: '', + lastSentContent: '', + startedAtMs, + timestamp: new Date(startedAtMs).toISOString(), + }; + activeThinkingBlocks.set(contentIndex, block); + return block; + }; + + const sendThinkingSnapshot = ( + block: ActiveThinkingBlock, + isStreaming: boolean, + ): void => { + if (block.flushTimer) { + clearTimeout(block.flushTimer); + block.flushTimer = undefined; + } + if ( + (!block.content && (isStreaming || !block.lastSentContent)) + || (isStreaming && block.content === block.lastSentContent) + ) { + return; + } + + state = 'STREAMING'; + firstLiveEventSent = true; + writer.send( + createNormalizedMessage({ + id: block.id, + kind: 'thinking', + provider: 'pi', + sessionId: boundSessionId ?? null, + timestamp: block.timestamp, + content: block.content, + isStreaming, + duration: isStreaming + ? undefined + : Math.max(1, Math.ceil((Date.now() - block.startedAtMs) / 1000)), + }), + ); + block.lastSentContent = block.content; + }; + + const scheduleThinkingSnapshot = (block: ActiveThinkingBlock): void => { + if (!block.lastSentContent || thinkingFlushMs === 0) { + sendThinkingSnapshot(block, true); + return; + } + if (!block.flushTimer) { + block.flushTimer = setTimeout(() => { + block.flushTimer = undefined; + sendThinkingSnapshot(block, true); + }, thinkingFlushMs); + } + }; + + const finalizeThinkingBlock = ( + contentIndex: number, + authoritativeContent?: string, + ): void => { + const block = activeThinkingBlocks.get(contentIndex); + if (!block) { + if (authoritativeContent) { + const recovered = createThinkingBlock(contentIndex); + recovered.content = authoritativeContent; + sendThinkingSnapshot(recovered, false); + activeThinkingBlocks.delete(contentIndex); + } + return; + } + + if (authoritativeContent !== undefined) { + block.content = authoritativeContent; + } + sendThinkingSnapshot(block, false); + activeThinkingBlocks.delete(contentIndex); + }; + + const finalizeAllThinkingBlocks = (): void => { + for (const contentIndex of [...activeThinkingBlocks.keys()]) { + finalizeThinkingBlock(contentIndex); + } + }; + + const finish = (outcome: PiRunOutcome, closeRpc = true): void => { + if (settled) return; + finalizeAllThinkingBlocks(); + settled = true; + state = 'SETTLED'; + if (abortTimer) clearTimeout(abortTimer); + activeRuns.delete(runId); + + if (outcome.status === 'settled') { + writer.send( + createCompleteMessage({ + provider: 'pi', + sessionId: outcome.sessionId, + exitCode: 0, + }), + ); + } else if (outcome.status === 'aborted') { + writer.send( + createCompleteMessage({ + provider: 'pi', + sessionId: outcome.sessionId, + exitCode: null, + aborted: true, + }), + ); + } else { + writer.send( + createNormalizedMessage({ + kind: 'error', + provider: 'pi', + sessionId: outcome.sessionId ?? null, + content: outcome.errorCode ?? 'ERR-PI-RUN-FAILED', + code: outcome.errorCode, + }), + ); + writer.send( + createCompleteMessage({ + provider: 'pi', + sessionId: outcome.sessionId, + exitCode: 1, + }), + ); + } + if (closeRpc) { + void rpc.close(RUN_CLOSE_GRACE_MS).finally(() => resolve(outcome)); + } else { + resolve(outcome); + } + }; + + const beginAbort = (): void => { + if (settled || aborting) return; + aborting = true; + // Send the abort request; wait a bounded window for agent_settled, then + // force-kill. Either path settles the run as aborted exactly once. + void rpc.abort().catch(() => undefined); + abortTimer = setTimeout(() => { + void rpc.close(0).finally(() => { + finish({ status: 'aborted', sessionId: boundSessionId }, false); + }); + }, abortGraceMs); + }; + + activeRuns.set(runId, { runId, sessionId: appSessionId, abort: beginAbort }); + + const signal: AbortSignal | undefined = + options.signal instanceof AbortSignal ? options.signal : undefined; + if (signal) { + if (signal.aborted) { + // Abort before we even start: settle immediately as aborted. + queueMicrotask(() => finish({ status: 'aborted', sessionId: boundSessionId })); + return; + } + signal.addEventListener('abort', beginAbort, { once: true }); + } + + const handleEvent = (event: unknown): void => { + // Once a terminal outcome is reached (settled/aborted/failed) any late + // native event is ignored, so a single run yields exactly one terminal. + if (settled) return; + + if (isSettledEvent(event)) { + // A settle that lands during the grace window is the abort taking + // effect, not a successful turn. + finish( + aborting + ? { status: 'aborted', sessionId: boundSessionId } + : { status: 'settled', sessionId: boundSessionId }, + ); + return; + } + + let normalized: NormalizedPiEvent | null; + try { + normalized = mapPiEvent(event); + } catch { + finish({ status: 'failed', sessionId: boundSessionId, errorCode: 'ERR-PI-RPC-PROTOCOL' }); + return; + } + + if (!normalized) { + const type = isRecord(event) ? event.type : undefined; + console.debug('[Pi] ignoring unmapped event', type); + return; + } + + if (normalized.kind === 'thinking_start') { + const existing = activeThinkingBlocks.get(normalized.contentIndex); + if (existing) { + finalizeThinkingBlock(normalized.contentIndex); + } + createThinkingBlock(normalized.contentIndex); + return; + } + + if (normalized.kind === 'thinking_delta') { + const block = createThinkingBlock(normalized.contentIndex); + block.content += normalized.content; + scheduleThinkingSnapshot(block); + return; + } + + if (normalized.kind === 'thinking_end') { + finalizeThinkingBlock(normalized.contentIndex, normalized.content); + return; + } + + // Native thinking_end should arrive first. This fallback prevents a + // malformed or provider-specific sequence from leaving the UI active. + finalizeAllThinkingBlocks(); + + state = 'STREAMING'; + firstLiveEventSent = true; + writer.send( + createNormalizedMessage({ + ...normalized, + provider: 'pi', + sessionId: boundSessionId ?? null, + }), + ); + }; + + // Kick off the state machine. Any failure before settle is a run failure. + void (async () => { + try { + await rpc.start(); + if (settled) return; + + state = 'REQUESTING_STATE'; + const rpcState = await rpc.getState(); + if (settled) return; + + state = 'BINDING_SESSION'; + bindSession(rpcState); + + rpc.onEvent(handleEvent); + if (rpc.onClose) { + rpc.onClose(() => { + if (settled || aborting) return; + // Process exited before agent_settled: never report success. + finish({ + status: 'failed', + sessionId: boundSessionId, + errorCode: 'ERR-PI-RUN-FAILED', + }); + }); + } + + state = 'PROMPTING'; + await rpc.prompt(command, images); + } catch { + if (settled || aborting) return; + // Process closed / start failed before agent_settled. + finish({ status: 'failed', sessionId: boundSessionId, errorCode: 'ERR-PI-RUN-FAILED' }); + } + })(); + + function bindSession(rpcState: RpcSessionState): void { + const nativeId = typeof rpcState?.sessionId === 'string' ? rpcState.sessionId : null; + if (!nativeId) return; + + boundSessionId = nativeId; + + if (writer.setSessionId) writer.setSessionId(nativeId); + + // Only emit a fresh binding when the app session was not already mapped + // to a native id (avoids a duplicate bind on the second turn). + if (!existingNativeSessionId && !firstLiveEventSent) { + writer.send( + createNormalizedMessage({ + kind: 'session_created', + provider: 'pi', + sessionId: nativeId, + newSessionId: nativeId, + }), + ); + } + } + }); + } + + function abort(sessionId: string): boolean { + let aborted = false; + // Ownership is by runId; find the run(s) belonging to this app session. + for (const active of activeRuns.values()) { + if (active.sessionId === sessionId) { + active.abort(); + aborted = true; + } + } + return aborted; + } + + return { run, abort }; +} + +/** Default runtime instance used by the provider registry. */ +export const piRuntime = createPiRuntime(); diff --git a/server/modules/providers/list/pi/pi-session-store.provider.test.ts b/server/modules/providers/list/pi/pi-session-store.provider.test.ts new file mode 100644 index 0000000000..dc6ae0e27d --- /dev/null +++ b/server/modules/providers/list/pi/pi-session-store.provider.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { after, test } from 'node:test'; + +import { PiSessionStore } from './pi-session-store.provider.js'; + +const tmpRoot = mkdtempSync(path.join(os.tmpdir(), 'pi-session-store-')); + +after(() => { + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +let counter = 0; +function writeJsonl(lines: string[], { trailingNewline = true } = {}): string { + const file = path.join(tmpRoot, `session-${counter++}.jsonl`); + const body = lines.join('\n') + (trailingNewline ? '\n' : ''); + writeFileSync(file, body, 'utf8'); + return file; +} + +const NO_VERSION = Symbol('no-version'); +function header(version: number | typeof NO_VERSION = 3): string { + const h: Record = { + type: 'session', + id: '00000000-0000-4000-8000-000000000000', + timestamp: '2026-08-03T00:00:00.000Z', + cwd: '/tmp/fake-workspace', + }; + if (version !== NO_VERSION) h.version = version; + return JSON.stringify(h); +} + +function completeUsage(overrides: Record = {}) { + return { + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + ...overrides, + }; +} + +function assistantEntry( + id: string, + parentId: string | null, + opts: { stopReason?: string; usage?: unknown; text?: string } = {}, +): string { + return JSON.stringify({ + type: 'message', + id, + parentId, + timestamp: '2026-08-03T00:00:00.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: opts.text ?? 'hi' }], + model: 'model-a', + provider: 'anthropic', + stopReason: opts.stopReason ?? 'stop', + usage: 'usage' in opts ? opts.usage : completeUsage(), + }, + }); +} + +function userEntry(id: string, parentId: string | null): string { + return JSON.stringify({ + type: 'message', + id, + parentId, + timestamp: '2026-08-03T00:00:00.000Z', + message: { role: 'user', content: 'hello', timestamp: 0 }, + }); +} + +// T14 +test('T14 有效 v3 header 归一化 active branch history', () => { + const file = writeJsonl([ + header(3), + userEntry('u1', null), + assistantEntry('a1', 'u1'), + ]); + const snap = PiSessionStore.load(file); + assert.equal(snap.header.version, 3); + assert.equal(snap.header.cwd, '/tmp/fake-workspace'); + assert.deepEqual(snap.entries.map((e) => e.id), ['u1', 'a1']); + assert.equal(snap.messages.length, 2); + assert.equal(snap.messages[0].role, 'user'); +}); + +// T15 +test('T15 尾部半行忽略,返回其余', () => { + const good = [header(3), userEntry('u1', null), assistantEntry('a1', 'u1')]; + const file = writeJsonl([...good, '{"type":"message","id":"a2"'], { + trailingNewline: false, + }); + const snap = PiSessionStore.load(file); + assert.deepEqual(snap.entries.map((e) => e.id), ['u1', 'a1']); +}); + +// T16 +test('T16 中间损坏行抛 ERR-PI-SESSION-CORRUPT 含行号', () => { + const file = writeJsonl([ + header(3), + userEntry('u1', null), + '{ broken json here', + assistantEntry('a1', 'u1'), + ]); + assert.throws( + () => PiSessionStore.load(file), + (err: unknown) => { + const e = err as { code?: string; message: string }; + return e.code === 'PI_SESSION_CORRUPT' && /3/.test(e.message); + }, + ); +}); + +// T17 +test('T17 不支持版本抛 ERR-PI-SESSION-VERSION-UNSUPPORTED', () => { + const file = writeJsonl([header(2), userEntry('u1', null)]); + assert.throws( + () => PiSessionStore.load(file), + (err: unknown) => (err as { code?: string }).code === 'PI_SESSION_VERSION_UNSUPPORTED', + ); + + const fileNoVersion = writeJsonl([header(NO_VERSION), userEntry('u1', null)]); + assert.throws( + () => PiSessionStore.load(fileNoVersion), + (err: unknown) => (err as { code?: string }).code === 'PI_SESSION_VERSION_UNSUPPORTED', + ); +}); + +// T21 +test('T21 active branch 最后 model_change 决定当前模型', () => { + const modelChange = (id: string, parentId: string, modelId: string) => + JSON.stringify({ + type: 'model_change', + id, + parentId, + timestamp: '2026-08-03T00:00:00.000Z', + provider: 'anthropic', + modelId, + }); + const file = writeJsonl([ + header(3), + userEntry('u1', null), + modelChange('m1', 'u1', 'model-old'), + assistantEntry('a1', 'm1'), + modelChange('m2', 'a1', 'model-new'), + ]); + const snap = PiSessionStore.load(file); + assert.deepEqual(snap.currentModel, { provider: 'anthropic', modelId: 'model-new' }); +}); + +// T22 +test('T22 最后一个非 error/未中止且 usage 完整返回该 usage', () => { + const file = writeJsonl([ + header(3), + userEntry('u1', null), + assistantEntry('a1', 'u1', { usage: completeUsage({ totalTokens: 111 }) }), + userEntry('u2', 'a1'), + assistantEntry('a2', 'u2', { stopReason: 'error', usage: completeUsage({ totalTokens: 999 }) }), + ]); + const snap = PiSessionStore.load(file); + assert.ok(snap.lastUsage); + assert.equal(snap.lastUsage?.totalTokens, 111); +}); + +// T23 +test('T23 无满足条件 usage 返回无 usage', () => { + const file = writeJsonl([ + header(3), + userEntry('u1', null), + assistantEntry('a1', 'u1', { stopReason: 'aborted' }), + assistantEntry('a2', 'a1', { stopReason: 'error' }), + ]); + const snap = PiSessionStore.load(file); + assert.equal(snap.lastUsage, null); +}); + +// 不可变性 +test('快照为不可变', () => { + const file = writeJsonl([header(3), userEntry('u1', null)]); + const snap = PiSessionStore.load(file); + assert.ok(Object.isFrozen(snap)); + assert.ok(Object.isFrozen(snap.entries)); +}); diff --git a/server/modules/providers/list/pi/pi-session-store.provider.ts b/server/modules/providers/list/pi/pi-session-store.provider.ts new file mode 100644 index 0000000000..ff2de96323 --- /dev/null +++ b/server/modules/providers/list/pi/pi-session-store.provider.ts @@ -0,0 +1,283 @@ +import { readFileSync } from 'node:fs'; + +import { AppError } from '@/shared/utils.js'; + +/** + * Current Pi session schema version. Sessions with a different (or missing) + * version are treated as unsupported. + */ +const CURRENT_SESSION_VERSION = 3; + +/** Header line of a Pi session JSONL file. */ +export interface PiSessionHeader { + type: 'session'; + version: number; + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +/** Common shape shared by every session entry (message, model_change, ...). */ +export interface PiSessionEntry { + type: string; + id: string; + parentId: string | null; + timestamp: string; + [key: string]: unknown; +} + +/** Token usage as reported by Pi (subset of the fields we depend on). */ +export interface PiUsage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + [key: string]: unknown; +} + +/** Normalized message on the active branch. */ +export interface PiSessionMessage { + entryId: string; + role: string; + message: Record; +} + +/** Immutable snapshot produced by a single parse of a session file. */ +export interface PiSessionSnapshot { + readonly header: PiSessionHeader; + readonly entries: ReadonlyArray; + readonly messages: ReadonlyArray; + readonly currentModel: { provider: string; modelId: string } | null; + readonly lastUsage: PiUsage | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Parses a Pi session JSONL file into an immutable snapshot. + * + * Parsing rules: + * - The first line is the header; an unsupported/missing version throws + * `PI_SESSION_VERSION_UNSUPPORTED`. + * - A trailing partial line (no terminating newline and invalid JSON) is + * ignored. + * - Any other invalid line throws `PI_SESSION_CORRUPT` with a 1-indexed line + * number. + * - The active branch is reconstructed by walking parentId links from the + * most-recently appended leaf back to the root, detecting cycles, duplicate + * ids, and missing parents. + */ +export const PiSessionStore = { + load(filePath: string): PiSessionSnapshot { + const raw = readFileSync(filePath, 'utf8'); + const endsWithNewline = raw.endsWith('\n'); + const lines = raw.split('\n'); + + // split on '\n' leaves a trailing empty string when the file ends with a + // newline; drop it so it is not mistaken for a data line. + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + + if (lines.length === 0) { + throw new AppError('Pi session 文件为空', { code: 'PI_SESSION_CORRUPT' }); + } + + const header = parseHeader(lines[0]); + + const entries: PiSessionEntry[] = []; + for (let i = 1; i < lines.length; i++) { + const isLastLine = i === lines.length - 1; + const lineNumber = i + 1; + let parsed: unknown; + try { + parsed = JSON.parse(lines[i]); + } catch { + // A trailing half-written line (no terminating newline) is ignored. + if (isLastLine && !endsWithNewline) { + continue; + } + throw new AppError(`Pi session 文件损坏(行号 ${lineNumber})`, { + code: 'PI_SESSION_CORRUPT', + details: { line: lineNumber }, + }); + } + if (!isValidEntry(parsed)) { + if (isLastLine && !endsWithNewline) { + continue; + } + throw new AppError(`Pi session 文件损坏(行号 ${lineNumber})`, { + code: 'PI_SESSION_CORRUPT', + details: { line: lineNumber }, + }); + } + entries.push(parsed); + } + + const branch = resolveActiveBranch(entries); + const messages = normalizeMessages(branch); + const currentModel = findLastModelChange(branch); + const lastUsage = findLastValidUsage(branch); + + return Object.freeze({ + header: Object.freeze(header), + entries: Object.freeze(branch), + messages: Object.freeze(messages), + currentModel: currentModel ? Object.freeze(currentModel) : null, + lastUsage: lastUsage ? Object.freeze(lastUsage) : null, + }) as PiSessionSnapshot; + }, +}; + +function parseHeader(line: string): PiSessionHeader { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + throw new AppError('Pi session 文件损坏(行号 1)', { + code: 'PI_SESSION_CORRUPT', + details: { line: 1 }, + }); + } + if (!isRecord(parsed) || parsed.type !== 'session') { + throw new AppError('Pi session 文件损坏(行号 1)', { + code: 'PI_SESSION_CORRUPT', + details: { line: 1 }, + }); + } + if (parsed.version !== CURRENT_SESSION_VERSION) { + throw new AppError('不支持的 Pi session 版本', { + code: 'PI_SESSION_VERSION_UNSUPPORTED', + details: { version: parsed.version ?? null }, + }); + } + return parsed as unknown as PiSessionHeader; +} + +function isValidEntry(value: unknown): value is PiSessionEntry { + if (!isRecord(value)) return false; + if (typeof value.type !== 'string') return false; + if (typeof value.id !== 'string') return false; + if (!('parentId' in value)) return false; + const parentId = value.parentId; + return parentId === null || typeof parentId === 'string'; +} + +/** + * Reconstructs the active branch as an ordered root->leaf list. + * + * The active leaf is the last appended entry. Walking parentId links back to + * the root detects cycles, duplicate ids, and missing parents (any of which + * corrupts the branch). + */ +function resolveActiveBranch(entries: PiSessionEntry[]): PiSessionEntry[] { + if (entries.length === 0) return []; + + const byId = new Map(); + for (const entry of entries) { + if (byId.has(entry.id)) { + throw new AppError('Pi session 文件损坏(重复 entry id)', { + code: 'PI_SESSION_CORRUPT', + details: { duplicateId: entry.id }, + }); + } + byId.set(entry.id, entry); + } + + const leaf = entries[entries.length - 1]; + const chain: PiSessionEntry[] = []; + const seen = new Set(); + let current: PiSessionEntry | undefined = leaf; + + while (current) { + if (seen.has(current.id)) { + throw new AppError('Pi session 文件损坏(active branch 存在环)', { + code: 'PI_SESSION_CORRUPT', + details: { cycleAt: current.id }, + }); + } + seen.add(current.id); + chain.push(current); + + if (current.parentId === null) break; + + const parent = byId.get(current.parentId); + if (!parent) { + throw new AppError('Pi session 文件损坏(丢失 parent entry)', { + code: 'PI_SESSION_CORRUPT', + details: { missingParent: current.parentId }, + }); + } + current = parent; + } + + return chain.reverse(); +} + +function normalizeMessages(branch: PiSessionEntry[]): PiSessionMessage[] { + const messages: PiSessionMessage[] = []; + for (const entry of branch) { + if (entry.type !== 'message') continue; + const message = entry.message; + if (!isRecord(message)) continue; + messages.push({ + entryId: entry.id, + role: typeof message.role === 'string' ? message.role : 'unknown', + message, + }); + } + return messages; +} + +function findLastModelChange( + branch: PiSessionEntry[], +): { provider: string; modelId: string } | null { + for (let i = branch.length - 1; i >= 0; i--) { + const entry = branch[i]; + if (entry.type !== 'model_change') continue; + if (typeof entry.provider === 'string' && typeof entry.modelId === 'string') { + return { provider: entry.provider, modelId: entry.modelId }; + } + } + return null; +} + +/** + * Returns the usage of the last assistant message on the active branch that + * did not error, was not aborted, and carries a complete usage payload. When + * no entry qualifies, returns null (Pi does not fall back to another + * provider's default usage). + */ +function findLastValidUsage(branch: PiSessionEntry[]): PiUsage | null { + for (let i = branch.length - 1; i >= 0; i--) { + const entry = branch[i]; + if (entry.type !== 'message') continue; + const message = entry.message; + if (!isRecord(message) || message.role !== 'assistant') continue; + if (message.stopReason === 'error' || message.stopReason === 'aborted') continue; + if (isCompleteUsage(message.usage)) { + return message.usage; + } + } + return null; +} + +function isCompleteUsage(value: unknown): value is PiUsage { + if (!isRecord(value)) return false; + const numericFields = ['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens']; + for (const field of numericFields) { + if (typeof value[field] !== 'number') return false; + } + return isRecord(value.cost) && typeof value.cost.total === 'number'; +} diff --git a/server/modules/providers/list/pi/pi-session-synchronizer.provider.test.ts b/server/modules/providers/list/pi/pi-session-synchronizer.provider.test.ts new file mode 100644 index 0000000000..eb19ac4d1d --- /dev/null +++ b/server/modules/providers/list/pi/pi-session-synchronizer.provider.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { PiSessionSynchronizer } from '@/modules/providers/list/pi/pi-session-synchronizer.provider.js'; + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'pi-provider-db-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +const SESSION_ID = '00000000-0000-4000-8000-000000000abc'; +const WORKSPACE = '/tmp/pi-fake-workspace'; + +/** Writes a de-identified v3 Pi session JSONL and returns its path. */ +async function writePiSession( + sessionRoot: string, + sessionId: string, + fileName = 'session.jsonl', +): Promise { + await mkdir(sessionRoot, { recursive: true }); + const lines = [ + JSON.stringify({ + type: 'session', + version: 3, + id: sessionId, + timestamp: '2026-08-03T00:00:00.000Z', + cwd: WORKSPACE, + }), + JSON.stringify({ + type: 'model_change', + id: 'e1', + parentId: null, + timestamp: '2026-08-03T00:00:01.000Z', + provider: 'anthropic', + modelId: 'claude-sonnet', + }), + JSON.stringify({ + type: 'message', + id: 'e2', + parentId: 'e1', + timestamp: '2026-08-03T00:00:02.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + }), + ]; + const filePath = path.join(sessionRoot, fileName); + await writeFile(filePath, `${lines.join('\n')}\n`, 'utf8'); + return filePath; +} + +test('Pi synchronizer discovers a new session and upserts its metadata', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'pi-session-sync-')); + const sessionRoot = path.join(tempRoot, 'sessions'); + const original = process.env.PI_CODING_AGENT_SESSION_DIR; + process.env.PI_CODING_AGENT_SESSION_DIR = sessionRoot; + + try { + const filePath = await writePiSession(sessionRoot, SESSION_ID); + await withIsolatedDatabase(async () => { + const synchronizer = new PiSessionSynchronizer(); + const processed = await synchronizer.synchronize(); + + assert.equal(processed, 1); + const row = sessionsDb.getSessionByProviderSessionId(SESSION_ID); + assert.ok(row, 'session should be upserted'); + assert.equal(row?.provider, 'pi'); + assert.equal(row?.project_path, WORKSPACE); + assert.equal(row?.jsonl_path, filePath); + assert.equal(row?.model, 'anthropic/claude-sonnet'); + }); + } finally { + if (original === undefined) { + delete process.env.PI_CODING_AGENT_SESSION_DIR; + } else { + process.env.PI_CODING_AGENT_SESSION_DIR = original; + } + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('Pi synchronizer skips a corrupt file and still upserts the good one', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'pi-session-sync-bad-')); + const sessionRoot = path.join(tempRoot, 'sessions'); + const original = process.env.PI_CODING_AGENT_SESSION_DIR; + process.env.PI_CODING_AGENT_SESSION_DIR = sessionRoot; + + try { + await writePiSession(sessionRoot, SESSION_ID, 'good.jsonl'); + await mkdir(sessionRoot, { recursive: true }); + // Corrupt: valid header then an invalid middle line (throws PI_SESSION_CORRUPT). + const corruptLines = [ + JSON.stringify({ type: 'session', version: 3, id: 'bad-id', timestamp: '2026-08-03T00:00:00.000Z', cwd: WORKSPACE }), + '{ not valid json', + JSON.stringify({ type: 'message', id: 'x', parentId: null, timestamp: '2026-08-03T00:00:02.000Z' }), + ]; + await writeFile(path.join(sessionRoot, 'bad.jsonl'), `${corruptLines.join('\n')}\n`, 'utf8'); + + await withIsolatedDatabase(async () => { + const synchronizer = new PiSessionSynchronizer(); + let processed = 0; + await assert.doesNotReject(async () => { + processed = await synchronizer.synchronize(); + }); + + assert.equal(processed, 1); + assert.ok(sessionsDb.getSessionByProviderSessionId(SESSION_ID), 'good session upserted'); + assert.equal(sessionsDb.getSessionByProviderSessionId('bad-id'), null, 'corrupt session skipped'); + }); + } finally { + if (original === undefined) { + delete process.env.PI_CODING_AGENT_SESSION_DIR; + } else { + process.env.PI_CODING_AGENT_SESSION_DIR = original; + } + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/server/modules/providers/list/pi/pi-session-synchronizer.provider.ts b/server/modules/providers/list/pi/pi-session-synchronizer.provider.ts new file mode 100644 index 0000000000..7651ba0f77 --- /dev/null +++ b/server/modules/providers/list/pi/pi-session-synchronizer.provider.ts @@ -0,0 +1,101 @@ +import { sessionsDb } from '@/modules/database/index.js'; +import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; +import { + findFilesRecursivelyCreatedAfter, + normalizeSessionName, + normalizeProviderTimestamp, +} from '@/shared/utils.js'; + +import { PiPaths } from './pi-paths.provider.js'; +import { PiSessionStore } from './pi-session-store.provider.js'; + +const FALLBACK_SESSION_NAME = 'Untitled Pi Session'; + +/** + * Session indexer for Pi transcript artifacts. + * + * Scans the resolved Pi session root(s) for `.jsonl` files, parses each into an + * immutable snapshot, and upserts its metadata into the shared sessions table. + * A single unreadable/corrupt file is logged and skipped so one bad artifact + * never aborts the rest of the scan. + */ +export class PiSessionSynchronizer implements IProviderSessionSynchronizer { + private readonly provider = 'pi' as const; + private readonly paths = new PiPaths(); + + /** + * Scans every Pi session root and upserts discovered sessions into DB. + */ + async synchronize(since?: Date): Promise { + let processed = 0; + for (const root of this.paths.getSessionRoots()) { + const files = await findFilesRecursivelyCreatedAfter(root, '.jsonl', since ?? null); + for (const filePath of files) { + try { + const sessionId = this.indexFile(filePath); + if (sessionId) { + processed += 1; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn('[PiProvider] Failed to synchronize session file:', filePath, message); + } + } + } + + return processed; + } + + /** + * Parses and upserts one Pi session JSONL file. + */ + async synchronizeFile(filePath: string): Promise { + if (!filePath.endsWith('.jsonl')) { + return null; + } + + try { + return this.indexFile(filePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn('[PiProvider] Failed to synchronize session file:', filePath, message); + return null; + } + } + + /** + * Parses a snapshot and upserts its metadata. Returns the stored session id. + */ + private indexFile(filePath: string): string | null { + const snapshot = PiSessionStore.load(filePath); + const sessionId = snapshot.header.id; + const projectPath = snapshot.header.cwd; + if (!sessionId || !projectPath) { + return null; + } + + const existingName = ( + sessionsDb.getSessionByProviderSessionId(sessionId) ?? sessionsDb.getSessionById(sessionId) + )?.custom_name; + const nextName = existingName && existingName !== FALLBACK_SESSION_NAME ? existingName : undefined; + + const storedId = sessionsDb.createSession( + sessionId, + this.provider, + projectPath, + normalizeSessionName(nextName, FALLBACK_SESSION_NAME), + normalizeProviderTimestamp(snapshot.header.timestamp), + normalizeProviderTimestamp(snapshot.header.timestamp), + filePath, + ); + + if (snapshot.currentModel) { + sessionsDb.setSessionModel( + storedId, + `${snapshot.currentModel.provider}/${snapshot.currentModel.modelId}`, + ); + } + + return storedId; + } +} diff --git a/server/modules/providers/list/pi/pi-sessions.provider.test.ts b/server/modules/providers/list/pi/pi-sessions.provider.test.ts new file mode 100644 index 0000000000..b170e99ca1 --- /dev/null +++ b/server/modules/providers/list/pi/pi-sessions.provider.test.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { after, test } from 'node:test'; + +import { PiPaths } from './pi-paths.provider.js'; +import { PiSessionsProvider } from './pi-sessions.provider.js'; + +const tmpRoot = mkdtempSync(path.join(os.tmpdir(), 'pi-sessions-')); + +after(() => { + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +let counter = 0; +function writeSession( + lines: string[], + { trailingNewline = true } = {}, +): { sessionId: string; filePath: string; provider: PiSessionsProvider } { + const sessionId = `session-${counter++}`; + const root = path.join(tmpRoot, sessionId); + const file = path.join(root, `${sessionId}.jsonl`); + mkdirSync(root, { recursive: true }); + const body = lines.join('\n') + (trailingNewline ? '\n' : ''); + writeFileSync(file, body, 'utf8'); + + process.env.PI_CODING_AGENT_SESSION_DIR = root; + const provider = new PiSessionsProvider(new PiPaths()); + return { sessionId, filePath: file, provider }; +} + +function header(): string { + return JSON.stringify({ + type: 'session', + version: 3, + id: '00000000-0000-4000-8000-000000000000', + timestamp: '2026-08-03T00:00:00.000Z', + cwd: '/tmp/fake-workspace', + }); +} + +function userEntry(id: string, parentId: string | null): string { + return JSON.stringify({ + type: 'message', + id, + parentId, + timestamp: '2026-08-03T00:00:00.000Z', + message: { role: 'user', content: 'hello', timestamp: '2026-08-03T00:00:00.000Z' }, + }); +} + +function assistantEntry(id: string, parentId: string): string { + return JSON.stringify({ + type: 'message', + id, + parentId, + timestamp: '2026-08-03T00:00:01.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'hi there' }], + model: 'model-a', + provider: 'anthropic', + stopReason: 'stop', + timestamp: '2026-08-03T00:00:01.000Z', + }, + }); +} + +function assistantThinkingEntry(id: string, parentId: string): string { + return JSON.stringify({ + type: 'message', + id, + parentId, + timestamp: '2026-08-03T00:00:01.000Z', + message: { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'considering the request', thinkingSignature: 'private-signature' }, + { type: 'text', text: 'final answer' }, + ], + model: 'model-a', + provider: 'anthropic', + stopReason: 'stop', + timestamp: 1785722401000, + }, + }); +} + +function modelChangeEntry(id: string, parentId: string, provider: string, modelId: string): string { + return JSON.stringify({ + type: 'model_change', + id, + parentId, + timestamp: '2026-08-03T00:00:02.000Z', + provider, + modelId, + }); +} + +// T14 有效 v3 session -> active branch 归一化 history +test('T14 有效 v3 session 归一化 active branch history', async () => { + const { sessionId, provider } = writeSession([ + header(), + userEntry('u1', null), + assistantEntry('a1', 'u1'), + ]); + + const result = await provider.fetchHistory(sessionId); + + assert.equal(result.messages.length, 2); + assert.equal(result.total, 2); + assert.equal(result.messages[0].role, 'user'); + assert.equal(result.messages[0].id, 'u1:0'); + assert.equal(result.messages[0].content, 'hello'); + assert.equal(result.messages[1].role, 'assistant'); + assert.equal(result.messages[1].id, 'a1:0'); + assert.equal(result.messages[1].content, 'hi there'); + assert.equal(result.messages[0].provider, 'pi'); +}); + +test('uses the indexed transcript path when Pi nests sessions below its root', async () => { + const { filePath, provider } = writeSession([ + header(), + userEntry('u1', null), + assistantEntry('a1', 'u1'), + ]); + + const result = await provider.fetchHistory('app-session', { + providerSessionId: 'native-session-not-at-the-root', + sessionFilePath: filePath, + }); + + assert.equal(result.total, 2); + assert.equal(result.messages[0].sessionId, 'app-session'); + assert.equal(result.messages[1].content, 'hi there'); +}); + +test('preserves Pi thinking blocks and normalizes numeric message timestamps', async () => { + const { sessionId, provider } = writeSession([ + header(), + userEntry('u1', null), + assistantThinkingEntry('a1', 'u1'), + ]); + + const result = await provider.fetchHistory(sessionId); + + assert.equal(result.messages.length, 3); + assert.deepEqual( + result.messages.map((message) => ({ + id: message.id, + kind: message.kind, + content: message.content, + timestamp: message.timestamp, + })), + [ + { + id: 'u1:0', + kind: 'text', + content: 'hello', + timestamp: '2026-08-03T00:00:00.000Z', + }, + { + id: 'a1:0', + kind: 'thinking', + content: 'considering the request', + timestamp: '2026-08-03T02:00:01.000Z', + }, + { + id: 'a1:1', + kind: 'text', + content: 'final answer', + timestamp: '2026-08-03T02:00:01.000Z', + }, + ], + ); + assert.equal('thinkingSignature' in result.messages[1], false); +}); + +// T15 尾部半行 -> 忽略半行返回其余 +test('T15 尾部半行被忽略并返回其余历史', async () => { + const { sessionId, provider } = writeSession( + [ + header(), + userEntry('u1', null), + assistantEntry('a1', 'u1'), + '{"type":"message","id":"a2"', + ], + { trailingNewline: false }, + ); + + const result = await provider.fetchHistory(sessionId); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[0].id, 'u1:0'); + assert.equal(result.messages[1].id, 'a1:0'); +}); + +// T21 active branch 最后 model_change -> 当前模型 +test('T21 active branch 最后 model_change 作为当前模型透传', async () => { + const { sessionId, provider } = writeSession([ + header(), + userEntry('u1', null), + assistantEntry('a1', 'u1'), + modelChangeEntry('m1', 'a1', 'openai', 'gpt-x'), + ]); + + const result = await provider.fetchHistory(sessionId); + + assert.deepEqual(result.currentModel, { provider: 'openai', modelId: 'gpt-x' }); +}); diff --git a/server/modules/providers/list/pi/pi-sessions.provider.ts b/server/modules/providers/list/pi/pi-sessions.provider.ts new file mode 100644 index 0000000000..1dda568bb2 --- /dev/null +++ b/server/modules/providers/list/pi/pi-sessions.provider.ts @@ -0,0 +1,171 @@ +import fsSync from 'node:fs'; +import path from 'node:path'; + +import type { IProviderSessions } from '@/shared/interfaces.js'; +import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; +import { createNormalizedMessage, sliceTailPage } from '@/shared/utils.js'; + +import { PiPaths } from './pi-paths.provider.js'; +import { PiSessionStore, type PiSessionMessage, type PiSessionSnapshot } from './pi-session-store.provider.js'; + +const PROVIDER = 'pi'; + +/** + * Raw shape accepted by `normalizeMessage`: a single normalized message from a + * `PiSessionSnapshot` (see `PiSessionStore`). Each `content` block becomes one + * `NormalizedMessage` with a stable id `:`. + */ +type PiRawMessage = PiSessionMessage; + +/** + * History result augmented with the session's current model, so the sessions + * layer transparently forwards the snapshot's `currentModel` without a second + * file read. + */ +export type PiFetchHistoryResult = FetchHistoryResult & { + currentModel: PiSessionSnapshot['currentModel']; +}; + +function isPiRawMessage(value: unknown): value is PiRawMessage { + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + return typeof record.entryId === 'string' + && typeof record.role === 'string' + && typeof record.message === 'object' + && record.message !== null; +} + +function toRole(role: string): 'user' | 'assistant' | undefined { + return role === 'user' || role === 'assistant' ? role : undefined; +} + +type PiDisplayContentBlock = { + index: number; + kind: 'text' | 'thinking'; + content: string; +}; + +/** + * Extracts ordered user-visible content from a Pi message. The original array + * index is retained so REST message ids remain stable across repeated reads. + */ +function extractContentBlocks(message: Record): PiDisplayContentBlock[] { + const content = message.content; + + if (typeof content === 'string') { + return [{ index: 0, kind: 'text', content }]; + } + + if (Array.isArray(content)) { + const blocks: PiDisplayContentBlock[] = []; + content.forEach((block, index) => { + if (typeof block === 'object' && block !== null) { + const record = block as Record; + if (record.type === 'text' && typeof record.text === 'string') { + blocks.push({ index, kind: 'text', content: record.text }); + } else if (record.type === 'thinking' && typeof record.thinking === 'string') { + blocks.push({ index, kind: 'thinking', content: record.thinking }); + } + } + }); + return blocks; + } + + return []; +} + +function normalizeMessageTimestamp(timestamp: unknown): string | undefined { + if (typeof timestamp === 'string') { + return timestamp; + } + if (typeof timestamp === 'number' && Number.isFinite(timestamp)) { + const date = new Date(timestamp); + if (Number.isFinite(date.getTime())) { + return date.toISOString(); + } + } + return undefined; +} + +/** + * Sessions adapter for Pi. Consumes immutable `PiSessionStore` snapshots and + * exposes normalized history with the shared tail pagination contract. + */ +export class PiSessionsProvider implements IProviderSessions { + private readonly paths: PiPaths; + + constructor(paths: PiPaths = new PiPaths()) { + this.paths = paths; + } + + normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[] { + if (!isPiRawMessage(raw)) { + return []; + } + + const { entryId, role, message } = raw; + const timestamp = normalizeMessageTimestamp(message.timestamp); + const normalizedRole = toRole(role); + + return extractContentBlocks(message).map(({ index, kind, content }) => createNormalizedMessage({ + id: `${entryId}:${index}`, + sessionId: sessionId ?? '', + timestamp, + provider: PROVIDER, + kind, + content, + ...(kind === 'text' ? { role: normalizedRole } : {}), + })); + } + + async fetchHistory( + sessionId: string, + options: FetchHistoryOptions = {}, + ): Promise { + const { limit = null, offset = 0 } = options; + const providerSessionId = options.providerSessionId ?? sessionId; + + const indexedFilePath = options.sessionFilePath; + const filePath = indexedFilePath && fsSync.existsSync(indexedFilePath) + ? indexedFilePath + : this.resolveSessionFile(providerSessionId); + if (!filePath) { + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null, currentModel: null }; + } + + const snapshot = PiSessionStore.load(filePath); + + const normalized: NormalizedMessage[] = []; + for (const message of snapshot.messages) { + normalized.push(...this.normalizeMessage(message, sessionId)); + } + + const normalizedOffset = Math.max(0, offset); + const normalizedLimit = limit === null ? null : Math.max(0, limit); + const total = normalized.length; + const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); + + return { + messages: page, + total, + hasMore, + offset: normalizedOffset, + limit: normalizedLimit, + currentModel: snapshot.currentModel, + }; + } + + /** + * Resolves a session id to its `.jsonl` file by scanning the configured + * session roots. Returns null when no matching file exists. + */ + private resolveSessionFile(sessionId: string): string | null { + for (const root of this.paths.getSessionRoots()) { + const candidate = path.join(root, `${sessionId}.jsonl`); + if (fsSync.existsSync(candidate)) { + return candidate; + } + } + return null; + } +} diff --git a/server/modules/providers/list/pi/pi-skills.provider.test.ts b/server/modules/providers/list/pi/pi-skills.provider.test.ts new file mode 100644 index 0000000000..6a80093eef --- /dev/null +++ b/server/modules/providers/list/pi/pi-skills.provider.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { after, test } from 'node:test'; + +import { PiSkillsProvider, type PiSkillsRpcClient } from './pi-skills.provider.js'; + +const tmpRoot = mkdtempSync(path.join(os.tmpdir(), 'pi-skills-')); + +after(() => { + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +type Command = { name: string; description?: string; source: string }; + +function stubClient(commands: Command[]): { create: () => PiSkillsRpcClient; started: () => boolean } { + let started = false; + return { + started: () => started, + create: () => ({ + async start() { + started = true; + }, + async getCommands() { + return commands; + }, + async close() { + // no-op + }, + }), + }; +} + +// T24: get_commands filtered to source === 'skill', shown as /skill:. +test('T24 lists only skill commands with /skill: format', async () => { + const stub = stubClient([ + { name: 'review', description: 'Review code', source: 'skill' }, + { name: 'compact', source: 'extension' }, + { name: 'write-tests', description: 'Write tests', source: 'skill' }, + { name: 'plan', source: 'prompt' }, + ]); + + const provider = new PiSkillsProvider({ + paths: { getAgentDir: () => path.join(tmpRoot, 'agent') }, + createRpcClient: stub.create, + }); + + const skills = await provider.listSkills(); + + assert.equal(skills.length, 2); + assert.deepEqual( + skills.map((skill) => ({ name: skill.name, command: skill.command, provider: skill.provider })), + [ + { name: 'review', command: '/skill:review', provider: 'pi' }, + { name: 'write-tests', command: '/skill:write-tests', provider: 'pi' }, + ], + ); + assert.ok(stub.started()); +}); + +// Path traversal in a directory name is neutralized: the shared validation +// keeps writes contained inside the managed skill root. +test('addSkills contains directory names that attempt to escape the skill root', async () => { + const agentDir = path.join(tmpRoot, 'agent'); + const skillRoot = path.resolve(path.join(agentDir, 'skills')); + const provider = new PiSkillsProvider({ + paths: { getAgentDir: () => agentDir }, + createRpcClient: stubClient([]).create, + }); + + const [skill] = await provider.addSkills({ + entries: [{ directoryName: '../evil', content: '# Evil\n' }], + }); + + const resolved = path.resolve(skill.sourcePath); + assert.ok( + resolved.startsWith(`${skillRoot}${path.sep}`), + `expected ${resolved} to stay inside ${skillRoot}`, + ); +}); + +// A supporting file path that escapes the skill directory is rejected outright. +test('addSkills rejects supporting files that escape the skill directory', async () => { + const provider = new PiSkillsProvider({ + paths: { getAgentDir: () => path.join(tmpRoot, 'agent') }, + createRpcClient: stubClient([]).create, + }); + + await assert.rejects( + provider.addSkills({ + entries: [{ + directoryName: 'good', + content: '# Good\n', + files: [{ relativePath: '../escape.txt', content: 'x', encoding: 'utf8' }], + }], + }), + /must stay inside|invalid supporting file path/i, + ); +}); + +// removeSkill also neutralizes traversal: the target stays inside the root. +test('removeSkill contains directory names that attempt to escape the skill root', async () => { + const agentDir = path.join(tmpRoot, 'agent'); + const skillRoot = path.resolve(path.join(agentDir, 'skills')); + const provider = new PiSkillsProvider({ + paths: { getAgentDir: () => agentDir }, + createRpcClient: stubClient([]).create, + }); + + const result = await provider.removeSkill({ directoryName: '../../etc' }); + assert.equal(result.provider, 'pi'); + const resolvedTarget = path.resolve(path.join(skillRoot, result.directoryName)); + assert.ok( + resolvedTarget.startsWith(`${skillRoot}${path.sep}`), + `expected ${resolvedTarget} to stay inside ${skillRoot}`, + ); +}); diff --git a/server/modules/providers/list/pi/pi-skills.provider.ts b/server/modules/providers/list/pi/pi-skills.provider.ts new file mode 100644 index 0000000000..bc7a3b08cd --- /dev/null +++ b/server/modules/providers/list/pi/pi-skills.provider.ts @@ -0,0 +1,86 @@ +/** + * PiSkillsProvider - discovers Pi skills via RPC `get_commands` (filtering + * `source === 'skill'`) and manages skill files under `/skills`. + * + * Listing does not scan disk: Pi surfaces skills through the running agent, so + * the RPC command list is the source of truth. Skills are presented with the + * `/skill:` invocation format, which differs from OpenCode's `/`. + * + * Writes (add/remove) reuse the shared {@link SkillsProvider} disk logic and its + * name/path validation (path traversal is rejected) with a Pi-specific global + * skill root at `/skills`. + */ +import path from 'node:path'; + +import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js'; +import type { ProviderSkill, ProviderSkillListOptions, ProviderSkillSource } from '@/shared/types.js'; + +import { PiPaths } from './pi-paths.provider.js'; +import { PiRpcClient } from './pi-rpc-client.provider.js'; + +/** Minimal RPC surface this provider depends on for skill discovery. */ +export interface PiSkillsRpcClient { + start(): Promise; + getCommands(): Promise>; + close(graceMs: number): Promise; +} + +export interface PiSkillsDeps { + paths?: Pick; + createRpcClient?: () => PiSkillsRpcClient; +} + +/** Format a skill name as Pi's `/skill:` invocation command. */ +const formatPiSkillCommand = (skillName: string): string => `/skill:${skillName}`; + +export class PiSkillsProvider extends SkillsProvider { + private readonly paths: Pick; + private readonly createRpcClient: () => PiSkillsRpcClient; + + constructor(deps: PiSkillsDeps = {}) { + super('pi'); + this.paths = deps.paths ?? new PiPaths(); + this.createRpcClient = deps.createRpcClient ?? (() => new PiRpcClient() as unknown as PiSkillsRpcClient); + } + + /** + * Lists Pi skills via RPC `get_commands`, keeping only `source === 'skill'` + * entries and presenting each with a `/skill:` command. + */ + async listSkills(_options?: ProviderSkillListOptions): Promise { + const client = this.createRpcClient(); + await client.start(); + try { + const commands = await client.getCommands(); + return commands + .filter((command) => command.source === 'skill') + .map((command) => ({ + provider: this.provider, + name: command.name, + description: command.description ?? '', + command: formatPiSkillCommand(command.name), + scope: 'user' as const, + sourcePath: path.join(this.getSkillRoot(), command.name), + })); + } finally { + await client.close(0); + } + } + + protected async getSkillSources(): Promise { + // Listing is served via RPC (see listSkills); disk sources are unused. + return []; + } + + protected async getGlobalSkillSource(): Promise { + return { + scope: 'user', + rootDir: this.getSkillRoot(), + commandForSkill: formatPiSkillCommand, + }; + } + + private getSkillRoot(): string { + return path.join(this.paths.getAgentDir(), 'skills'); + } +} diff --git a/server/modules/providers/list/pi/pi-token-usage.provider.test.ts b/server/modules/providers/list/pi/pi-token-usage.provider.test.ts new file mode 100644 index 0000000000..a34d3d4f24 --- /dev/null +++ b/server/modules/providers/list/pi/pi-token-usage.provider.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { PiTokenUsageProvider } from './pi-token-usage.provider.js'; +import type { PiUsage } from './pi-session-store.provider.js'; + +function completeUsage(overrides: Partial = {}): PiUsage { + return { + input: 10, + output: 20, + cacheRead: 3, + cacheWrite: 5, + totalTokens: 30, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + ...overrides, + }; +} + +// T22: snapshot has a last valid usage -> pass it through unchanged. +test('T22 returns the last valid usage from the snapshot', () => { + const usage = completeUsage(); + const provider = new PiTokenUsageProvider({ + load: () => ({ lastUsage: usage }), + }); + + const result = provider.getTokenUsage('/tmp/session.jsonl'); + + assert.deepEqual(result, { + used: 30, + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 3, + cacheCreationTokens: 5, + cacheTokens: 8, + breakdown: { input: 10, output: 20 }, + }); +}); + +// T23: no qualifying usage -> null, not another provider's default. +test('T23 returns null when the snapshot has no valid usage', () => { + const provider = new PiTokenUsageProvider({ + load: () => ({ lastUsage: null }), + }); + + assert.equal(provider.getTokenUsage('/tmp/session.jsonl'), null); +}); diff --git a/server/modules/providers/list/pi/pi-token-usage.provider.ts b/server/modules/providers/list/pi/pi-token-usage.provider.ts new file mode 100644 index 0000000000..2d08433fbc --- /dev/null +++ b/server/modules/providers/list/pi/pi-token-usage.provider.ts @@ -0,0 +1,57 @@ +/** + * PiTokenUsageProvider - derives a session's token usage from the last valid + * assistant usage on the active branch, as computed by {@link PiSessionStore}. + * + * Pi does not fall back to another provider's default usage: when the session + * snapshot carries no qualifying usage, `getTokenUsage` returns `null`. + */ +import { PiSessionStore } from './pi-session-store.provider.js'; + +/** Token usage shape returned to the central token-usage service. */ +export interface PiTokenUsage { + used: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + cacheTokens: number; + breakdown: { + input: number; + output: number; + }; +} + +type SessionLoader = (filePath: string) => { lastUsage: import('./pi-session-store.provider.js').PiUsage | null }; + +export class PiTokenUsageProvider { + private readonly load: SessionLoader; + + constructor(deps: { load?: SessionLoader } = {}) { + this.load = deps.load ?? ((filePath) => PiSessionStore.load(filePath)); + } + + /** + * Returns the token usage for a Pi session file, or `null` when the snapshot + * has no last valid usage. + */ + getTokenUsage(sessionFilePath: string): PiTokenUsage | null { + const snapshot = this.load(sessionFilePath); + const usage = snapshot.lastUsage; + if (!usage) { + return null; + } + + return { + used: usage.totalTokens, + inputTokens: usage.input, + outputTokens: usage.output, + cacheReadTokens: usage.cacheRead, + cacheCreationTokens: usage.cacheWrite, + cacheTokens: usage.cacheRead + usage.cacheWrite, + breakdown: { + input: usage.input, + output: usage.output, + }, + }; + } +} diff --git a/server/modules/providers/list/pi/pi.provider.test.ts b/server/modules/providers/list/pi/pi.provider.test.ts new file mode 100644 index 0000000000..6b3c436b0c --- /dev/null +++ b/server/modules/providers/list/pi/pi.provider.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PiProvider } from './pi.provider.js'; + +test('PiProvider assembles all IProvider facets with id "pi"', () => { + const provider = new PiProvider(); + + assert.equal(provider.id, 'pi'); + assert.ok(provider.runtime); + assert.equal(typeof provider.runtime.run, 'function'); + assert.equal(typeof provider.runtime.abort, 'function'); + assert.ok(provider.models); + assert.equal(typeof provider.models.getSupportedModels, 'function'); + assert.ok(provider.mcp); + assert.equal(typeof provider.mcp.listServers, 'function'); + assert.ok(provider.auth); + assert.equal(typeof provider.auth.getStatus, 'function'); + assert.ok(provider.skills); + assert.equal(typeof provider.skills.listSkills, 'function'); + assert.ok(provider.sessions); + assert.equal(typeof provider.sessions.normalizeMessage, 'function'); + assert.ok(provider.sessionSynchronizer); + assert.equal(typeof provider.sessionSynchronizer.synchronize, 'function'); +}); diff --git a/server/modules/providers/list/pi/pi.provider.ts b/server/modules/providers/list/pi/pi.provider.ts new file mode 100644 index 0000000000..2079514fbd --- /dev/null +++ b/server/modules/providers/list/pi/pi.provider.ts @@ -0,0 +1,69 @@ +/** + * PiProvider - assembles the Pi integration's eight facets behind one + * registry-owned object (see AbstractProvider / IProvider). + * + * Facets that left an injection seam are wired here to a real PiRpcClient: + * - models: `withProbe` spawns a PiRpcClient, runs the catalog probe, then + * closes it with a bounded grace window. + * - auth: uses its default PiRpcClient wiring. + * The runtime already defaults to the real PiRpcClient factory (with onClose). + */ +import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import type { + IProviderAuth, + IProviderMcp, + IProviderModels, + IProviderRuntime, + IProviderSessionSynchronizer, + IProviderSkills, + IProviderSessions, +} from '@/shared/interfaces.js'; + +import { PiAuthProvider } from './pi-auth.provider.js'; +import { + PiModelsProvider, + type PiModelsProbe, + type PiModelsRpc, +} from './pi-models.provider.js'; +import { PiMcpProvider } from './pi-mcp.provider.js'; +import { PiRpcClient } from './pi-rpc-client.provider.js'; +import { piRuntime } from './pi-runtime.provider.js'; +import { PiSessionSynchronizer } from './pi-session-synchronizer.provider.js'; +import { PiSessionsProvider } from './pi-sessions.provider.js'; +import { PiSkillsProvider } from './pi-skills.provider.js'; + +const MODELS_PROBE_GRACE_MS = 5000; + +/** + * Real {@link PiModelsRpc}: spawns a PiRpcClient probe, runs the supplied + * catalog function, and always closes the probe afterwards. + */ +const piModelsRpc: PiModelsRpc = { + async withProbe(fn) { + const client = new PiRpcClient(); + await client.start(); + try { + return await fn(client as unknown as PiModelsProbe); + } finally { + try { + await client.close(MODELS_PROBE_GRACE_MS); + } catch { + // ignore close failures during probe teardown + } + } + }, +}; + +export class PiProvider extends AbstractProvider { + readonly runtime: IProviderRuntime = piRuntime; + readonly models: IProviderModels = new PiModelsProvider(piModelsRpc); + readonly mcp: IProviderMcp = new PiMcpProvider(); + readonly auth: IProviderAuth = new PiAuthProvider(); + readonly skills: IProviderSkills = new PiSkillsProvider(); + readonly sessions: IProviderSessions = new PiSessionsProvider(); + readonly sessionSynchronizer: IProviderSessionSynchronizer = new PiSessionSynchronizer(); + + constructor() { + super('pi'); + } +} diff --git a/server/modules/providers/provider.registry.ts b/server/modules/providers/provider.registry.ts index 8c333fcb72..9a87df97f6 100644 --- a/server/modules/providers/provider.registry.ts +++ b/server/modules/providers/provider.registry.ts @@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider. import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js'; import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js'; import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js'; +import { PiProvider } from '@/modules/providers/list/pi/pi.provider.js'; import type { IProvider } from '@/shared/interfaces.js'; import type { LLMProvider } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; @@ -11,6 +12,7 @@ const providers: Record = { codex: new CodexProvider(), cursor: new CursorProvider(), opencode: new OpenCodeProvider(), + pi: new PiProvider(), }; /** diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index e11fadc436..8129e44038 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -1,5 +1,6 @@ import express, { type Request, type Response } from 'express'; +import { providerRegistry } from '@/modules/providers/provider.registry.js'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; @@ -281,19 +282,7 @@ const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateI const parseProvider = (value: unknown): LLMProvider => { const normalized = normalizeProviderParam(value); - if ( - normalized === 'claude' - || normalized === 'codex' - || normalized === 'cursor' - || normalized === 'opencode' - ) { - return normalized; - } - - throw new AppError(`Unsupported provider "${normalized}".`, { - code: 'UNSUPPORTED_PROVIDER', - statusCode: 400, - }); + return providerRegistry.resolveProvider(normalized).id; }; const parseSessionRenameSummary = (payload: unknown): string => { diff --git a/server/modules/providers/services/mcp.service.ts b/server/modules/providers/services/mcp.service.ts index 6e34e47223..fe5fe72603 100644 --- a/server/modules/providers/services/mcp.service.ts +++ b/server/modules/providers/services/mcp.service.ts @@ -70,6 +70,9 @@ export const providerMcpService = { await provider.mcp.upsertServer({ ...input, scope }); results.push({ provider: provider.id, created: true }); } catch (error) { + if (error instanceof AppError && error.code === 'PROVIDER_CAPABILITY_UNSUPPORTED') { + continue; + } results.push({ provider: provider.id, created: false, @@ -96,6 +99,9 @@ export const providerMcpService = { const result = await provider.mcp.removeServer(input); results.push({ provider: provider.id, removed: result.removed }); } catch (error) { + if (error instanceof AppError && error.code === 'PROVIDER_CAPABILITY_UNSUPPORTED') { + continue; + } results.push({ provider: provider.id, removed: false, diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index b506615571..8cbd09f48e 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -81,6 +81,17 @@ const PROVIDER_CAPABILITIES: Record = { supportsTokenUsage: true, supportsEffort: true, }, + pi: { + provider: 'pi', + permissionModes: ['plan', 'bypassPermissions'], + defaultPermissionMode: 'bypassPermissions', + supportsImages: true, + supportsFiles: true, + supportsAbort: true, + supportsPermissionRequests: false, + supportsTokenUsage: true, + supportsEffort: true, + }, }; /** diff --git a/server/modules/providers/services/provider-token-usage.service.ts b/server/modules/providers/services/provider-token-usage.service.ts index f7d1c23ad1..eb20ebe44a 100644 --- a/server/modules/providers/services/provider-token-usage.service.ts +++ b/server/modules/providers/services/provider-token-usage.service.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { sessionsDb } from '@/modules/database/index.js'; +import { PiTokenUsageProvider } from '@/modules/providers/list/pi/pi-token-usage.provider.js'; import type { AnyRecord } from '@/shared/types.js'; import { AppError, getOpenCodeDatabasePath } from '@/shared/utils.js'; @@ -311,6 +312,28 @@ export function createProviderTokenUsageService( return readCodexTokenUsage(fileContent); } + if (session.provider === 'pi') { + if (!session.jsonl_path) { + throw new AppError(`Pi session file for "${sessionId}" was not found.`, { + code: 'SESSION_FILE_NOT_FOUND', + statusCode: 404, + }); + } + + const usage = new PiTokenUsageProvider().getTokenUsage(session.jsonl_path); + if (!usage) { + return { + used: 0, + inputTokens: 0, + outputTokens: 0, + breakdown: { input: 0, output: 0 }, + message: 'No token usage recorded for this Pi session', + }; + } + + return usage; + } + let sessionFilePath = session.jsonl_path; if (!sessionFilePath) { if (!session.project_path) { diff --git a/server/modules/providers/services/session-synchronizer.service.ts b/server/modules/providers/services/session-synchronizer.service.ts index 5bb2b8645f..ed25225aa8 100644 --- a/server/modules/providers/services/session-synchronizer.service.ts +++ b/server/modules/providers/services/session-synchronizer.service.ts @@ -22,6 +22,7 @@ export const sessionSynchronizerService = { codex: 0, cursor: 0, opencode: 0, + pi: 0, }; const failures: string[] = []; diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 052afaeb91..9964967b4d 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -6,13 +6,14 @@ import chokidar, { type FSWatcher } from 'chokidar'; import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js'; +import { PiPaths } from '@/modules/providers/list/pi/pi-paths.provider.js'; import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js'; import type { LLMProvider } from '@/shared/types.js'; import { generateDisplayName } from '@/modules/projects/index.js'; type WatcherEventType = 'add' | 'change'; -const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = [ +export const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = [ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects'), @@ -29,6 +30,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = provider: 'opencode', rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'), }, + ...new PiPaths().getSessionRoots().map((rootPath) => ({ + provider: 'pi' as LLMProvider, + rootPath, + })), ]; const WATCHER_IGNORED_PATTERNS = [ diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 12c01ee6ea..b684e77047 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -214,6 +214,7 @@ export const sessionsService = { offset: options.offset ?? 0, projectPath: session.project_path ?? '', providerSessionId: session.provider_session_id, + sessionFilePath: session.jsonl_path ?? undefined, }); return { diff --git a/server/modules/providers/tests/mcp.test.ts b/server/modules/providers/tests/mcp.test.ts index 439966acd4..fe842fa28e 100644 --- a/server/modules/providers/tests/mcp.test.ts +++ b/server/modules/providers/tests/mcp.test.ts @@ -315,6 +315,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp assert.equal(globalResult.length, 4); assert.ok(globalResult.every((entry) => entry.created === true)); + assert.ok(globalResult.every((entry) => entry.provider !== 'pi')); const claudeProject = await readJson(path.join(workspacePath, '.mcp.json')); assert.ok((claudeProject.mcpServers as Record)['global-http']); diff --git a/server/modules/providers/tests/provider-capabilities.service.test.ts b/server/modules/providers/tests/provider-capabilities.service.test.ts new file mode 100644 index 0000000000..0a2dd96c4c --- /dev/null +++ b/server/modules/providers/tests/provider-capabilities.service.test.ts @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { providerCapabilitiesService } from '../services/provider-capabilities.service.js'; + +test('T25: pi capabilities expose only plan/bypassPermissions with bypassPermissions default', () => { + const caps = providerCapabilitiesService.getProviderCapabilities('pi'); + + assert.equal(caps.provider, 'pi'); + assert.deepEqual(caps.permissionModes, ['plan', 'bypassPermissions']); + assert.equal(caps.defaultPermissionMode, 'bypassPermissions'); + assert.equal(caps.supportsPermissionRequests, false); +}); + +test('pi capability facets match runtime support', () => { + const caps = providerCapabilitiesService.getProviderCapabilities('pi'); + + assert.equal(caps.supportsAbort, true); + assert.equal(caps.supportsTokenUsage, true); + assert.equal(caps.supportsEffort, true); + assert.equal(caps.supportsImages, true); + assert.equal(caps.supportsFiles, true); +}); diff --git a/server/modules/providers/tests/provider-registry.test.ts b/server/modules/providers/tests/provider-registry.test.ts new file mode 100644 index 0000000000..2405b16ed6 --- /dev/null +++ b/server/modules/providers/tests/provider-registry.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { PiProvider } from '@/modules/providers/list/pi/pi.provider.js'; +import { providerRegistry } from '@/modules/providers/provider.registry.js'; +import { AppError } from '@/shared/utils.js'; + +describe('providerRegistry', () => { + it('throws UNSUPPORTED_PROVIDER for an unregistered provider', () => { + try { + providerRegistry.resolveProvider('does-not-exist'); + assert.fail('expected resolveProvider to throw'); + } catch (error) { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'UNSUPPORTED_PROVIDER'); + assert.equal(error.statusCode, 400); + } + }); + + it('resolves pi to a PiProvider instance exposing its facets', () => { + const resolved = providerRegistry.resolveProvider('pi'); + + assert.ok(resolved instanceof PiProvider); + assert.equal(resolved.id, 'pi'); + assert.ok(resolved.runtime); + assert.ok(resolved.models); + assert.ok(resolved.mcp); + assert.ok(resolved.auth); + assert.ok(resolved.skills); + assert.ok(resolved.sessions); + assert.ok(resolved.sessionSynchronizer); + }); +}); diff --git a/server/modules/providers/tests/provider-runtime.service.test.ts b/server/modules/providers/tests/provider-runtime.service.test.ts index 16322a4e9e..731c147a53 100644 --- a/server/modules/providers/tests/provider-runtime.service.test.ts +++ b/server/modules/providers/tests/provider-runtime.service.test.ts @@ -80,6 +80,7 @@ test('providerRegistry owns one runtime for every registered provider', () => { 'codex', 'cursor', 'opencode', + 'pi', ]); assert.equal(providers.every((provider) => typeof provider.runtime.run === 'function'), true); assert.equal(providers.every((provider) => typeof provider.runtime.abort === 'function'), true); diff --git a/server/modules/providers/tests/provider-token-usage.service.test.ts b/server/modules/providers/tests/provider-token-usage.service.test.ts index 636da1ef85..5c28362c67 100644 --- a/server/modules/providers/tests/provider-token-usage.service.test.ts +++ b/server/modules/providers/tests/provider-token-usage.service.test.ts @@ -171,6 +171,104 @@ test('Cursor returns an explicit unsupported token usage result', async () => { assert.equal(result.total, 0); }); +test('Pi token usage returns the last valid usage snapshot (T22)', async () => { + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'provider-token-usage-pi-')); + const sessionFilePath = path.join(tempDirectory, 'pi-session.jsonl'); + + try { + await writeFile(sessionFilePath, [ + JSON.stringify({ + type: 'session', + version: 3, + id: 's1', + timestamp: '2026-01-01T00:00:00.000Z', + cwd: '/tmp', + }), + JSON.stringify({ + type: 'message', + id: 'e1', + parentId: null, + timestamp: '2026-01-01T00:00:01.000Z', + message: { + role: 'assistant', + stopReason: 'end_turn', + usage: { + input: 100, + output: 30, + cacheRead: 20, + cacheWrite: 5, + totalTokens: 155, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + }), + ].join('\n')); + + const service = createProviderTokenUsageService({ + getSessionById: () => createSessionRow({ provider: 'pi', jsonl_path: sessionFilePath }), + }); + + assert.deepEqual(await service.getSessionTokenUsage('app-session'), { + used: 155, + inputTokens: 100, + outputTokens: 30, + cacheReadTokens: 20, + cacheCreationTokens: 5, + cacheTokens: 25, + breakdown: { input: 100, output: 30 }, + }); + } finally { + await rm(tempDirectory, { recursive: true, force: true }); + } +}); + +test('Pi token usage reports no usage without falling back to .claude (T23)', async () => { + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'provider-token-usage-pi-empty-')); + const sessionFilePath = path.join(tempDirectory, 'pi-session.jsonl'); + + try { + await writeFile(sessionFilePath, [ + JSON.stringify({ + type: 'session', + version: 3, + id: 's1', + timestamp: '2026-01-01T00:00:00.000Z', + cwd: '/tmp', + }), + JSON.stringify({ + type: 'message', + id: 'e1', + parentId: null, + timestamp: '2026-01-01T00:00:01.000Z', + message: { role: 'assistant', stopReason: 'aborted' }, + }), + ].join('\n')); + + const service = createProviderTokenUsageService({ + getSessionById: () => createSessionRow({ provider: 'pi', jsonl_path: sessionFilePath }), + getHomeDirectory: () => { + throw new Error('Pi must not fall back to the .claude default path'); + }, + readTextFile: async (filePath) => { + if (filePath === sessionFilePath) { + throw new Error('Pi must read via PiSessionStore, not readTextFile'); + } + throw new Error('Pi must not fall back to the .claude default path'); + }, + }); + + const result = await service.getSessionTokenUsage('app-session'); + + assert.equal(result.used, 0); + assert.equal(result.inputTokens, 0); + assert.equal(result.outputTokens, 0); + assert.deepEqual(result.breakdown, { input: 0, output: 0 }); + assert.equal(typeof result.message, 'string'); + } finally { + await rm(tempDirectory, { recursive: true, force: true }); + } +}); + test('token usage reports SESSION_NOT_FOUND for an unknown app session id', async () => { const service = createProviderTokenUsageService({ getSessionById: () => null }); diff --git a/server/modules/providers/tests/provider.routes.test.ts b/server/modules/providers/tests/provider.routes.test.ts new file mode 100644 index 0000000000..96d2531ea8 --- /dev/null +++ b/server/modules/providers/tests/provider.routes.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import type { AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import express from 'express'; + +import providerRoutes from '@/modules/providers/provider.routes.js'; + +test('Pi auth status route accepts the registered provider', { concurrency: false }, async () => { + const previousCliPath = process.env.PI_CLI_PATH; + process.env.PI_CLI_PATH = path.join(os.tmpdir(), 'cloudcli-test-missing-pi-cli'); + + const app = express(); + app.use('/api/providers', providerRoutes); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + + try { + const address = server.address() as AddressInfo; + const response = await fetch( + `http://127.0.0.1:${address.port}/api/providers/pi/auth/status`, + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + success: true, + data: { + installed: false, + provider: 'pi', + authenticated: false, + email: null, + method: null, + error: 'Pi CLI not installed', + }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + if (previousCliPath === undefined) { + delete process.env.PI_CLI_PATH; + } else { + process.env.PI_CLI_PATH = previousCliPath; + } + } +}); diff --git a/server/modules/providers/tests/sessions-watcher-paths.test.ts b/server/modules/providers/tests/sessions-watcher-paths.test.ts new file mode 100644 index 0000000000..15f06dd797 --- /dev/null +++ b/server/modules/providers/tests/sessions-watcher-paths.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { PiPaths } from '@/modules/providers/list/pi/pi-paths.provider.js'; +import { PROVIDER_WATCH_PATHS } from '@/modules/providers/services/sessions-watcher.service.js'; + +test('PROVIDER_WATCH_PATHS includes a pi entry for each Pi session root', () => { + const piEntries = PROVIDER_WATCH_PATHS.filter((entry) => entry.provider === 'pi'); + assert.ok(piEntries.length > 0, 'expected at least one pi watch entry'); + + const expectedRoots = new PiPaths().getSessionRoots(); + const watchedRoots = piEntries.map((entry) => entry.rootPath); + for (const root of expectedRoots) { + assert.ok( + watchedRoots.includes(root), + `expected PROVIDER_WATCH_PATHS to watch pi session root ${root}` + ); + } +}); diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index 7d0013608c..7aacc53984 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -216,6 +216,13 @@ function buildShellCommand( return initialCommand || 'opencode'; } + if (provider === 'pi') { + if (resumeSessionId) { + return `pi --session "${resumeSessionId}"`; + } + return initialCommand || 'pi'; + } + const command = initialCommand || 'claude'; if (resumeSessionId) { if (os.platform() === 'win32') { @@ -282,6 +289,52 @@ function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): { key: string; valu return { key: pathKey, value }; } +/** + * Appends well-known user-level bin directories to PATH when they exist but are + * missing from it. npm's lifecycle rebuilds PATH and drops shell-rc entries such + * as ~/.local/bin, so CLIs installed there (e.g. claude) become unreachable in + * the spawned pty. Directories are appended (never prepended) so an already + * active toolchain earlier in PATH (fnm/homebrew node) keeps priority. + */ +function appendUserBinDirs(currentPath: string | undefined): string { + const delimiter = path.delimiter; + const home = os.homedir(); + const candidates = + os.platform() === 'win32' + ? [path.join(home, 'AppData', 'Roaming', 'npm')] + : [ + path.join(home, '.local', 'bin'), + path.join(home, 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + ]; + + const existingEntries = (currentPath ?? '').split(delimiter).filter(Boolean); + const existingSet = new Set( + os.platform() === 'win32' + ? existingEntries.map((entry) => entry.toLowerCase()) + : existingEntries + ); + + const additions = candidates.filter((candidate) => { + const normalized = os.platform() === 'win32' ? candidate.toLowerCase() : candidate; + if (existingSet.has(normalized)) { + return false; + } + try { + return fs.statSync(candidate).isDirectory(); + } catch { + return false; + } + }); + + if (additions.length === 0) { + return currentPath ?? ''; + } + + return [...existingEntries, ...additions].join(delimiter); +} + /** * Used by this module's websocket gateway to connect the standalone Shell UI * to a retained PTY while keeping process lifecycle ownership on the server. @@ -398,6 +451,7 @@ export function handleShellConnection( const termCols = readNumber(data.cols, 80); const termRows = readNumber(data.rows, 24); const prioritizedPath = prioritizeUserNpmGlobalBin(process.env); + prioritizedPath.value = appendUserBinDirs(prioritizedPath.value); shellProcess = (dependencies.spawnPty ?? pty.spawn)(shell, shellArgs, { name: 'xterm-256color', @@ -536,6 +590,8 @@ export function handleShellConnection( ? 'Codex' : provider === 'opencode' ? 'OpenCode' + : provider === 'pi' + ? 'Pi' : 'Claude'; welcomeMsg = hasSession && resumeSessionId ? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n` diff --git a/server/modules/websocket/tests/shell-websocket.service.test.ts b/server/modules/websocket/tests/shell-websocket.service.test.ts index 808b2dc8d8..a250dfba4b 100644 --- a/server/modules/websocket/tests/shell-websocket.service.test.ts +++ b/server/modules/websocket/tests/shell-websocket.service.test.ts @@ -117,3 +117,34 @@ test('shell output detects and normalizes a wrapped authentication URL', () => { pty.emitExit(); }); + +test('Pi agent terminals start Pi instead of falling back to Claude', () => { + const pty = createFakePty(); + const socket = createFakeSocket(); + let spawnedArguments: string[] = []; + const dependencies = { + resolveProviderSessionId: () => null, + spawnPty: (_shell: string, args: string | string[]) => { + spawnedArguments = typeof args === 'string' ? [args] : args; + return pty as never; + }, + }; + + handleShellConnection(socket as never, dependencies); + socket.emit( + 'message', + JSON.stringify({ + type: 'init', + projectPath: process.cwd(), + sessionId: `pi-shell-${Date.now()}`, + hasSession: false, + provider: 'pi', + }), + ); + + assert.match(spawnedArguments.join(' '), /\bpi\b/); + assert.doesNotMatch(spawnedArguments.join(' '), /\bclaude\b/); + assert.ok(socket.frames.some((frame) => frame.includes('Starting new Pi session'))); + + pty.emitExit(); +}); diff --git a/server/shared/types.ts b/server/shared/types.ts index f8db6b400b..64326c5f62 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -66,7 +66,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & { * Use this as the source of truth whenever a function or payload needs to identify * a specific LLM integration. */ -export type LLMProvider = 'claude' | 'codex' | 'cursor' | 'opencode'; +export type LLMProvider = 'claude' | 'codex' | 'cursor' | 'opencode' | 'pi'; /** * One selectable model row in a provider model catalog. @@ -226,6 +226,13 @@ export type NormalizedMessage = { seq?: number; role?: 'user' | 'assistant'; content?: string; + /** + * Whether this message is a mutable live snapshot. Pi thinking snapshots use + * this to drive the Reasoning progress state; the final snapshot sets false. + */ + isStreaming?: boolean; + /** Completed reasoning duration in whole seconds. Omitted while streaming. */ + duration?: number; /** * Optional display-oriented metadata used by providers that need to expose * richer transcript artifacts without introducing a brand-new message kind. @@ -331,12 +338,18 @@ export type ProviderRunFunction = ( * must use it — never the app-facing session id they were called with — when * matching transcript rows on disk, because app-created sessions use an * app-allocated id that the provider has never seen. + * + * `sessionFilePath` is the authoritative transcript path recorded by the + * session synchronizer. File-backed providers should prefer it over deriving + * a path from `providerSessionId`, because providers may nest or rename their + * transcript files independently of the native session id. */ export type FetchHistoryOptions = { projectPath?: string; limit?: number | null; offset?: number; providerSessionId?: string; + sessionFilePath?: string; }; /** diff --git a/src/components/auth/types.ts b/src/components/auth/types.ts index e745a3727f..99fa301ddd 100644 --- a/src/components/auth/types.ts +++ b/src/components/auth/types.ts @@ -11,7 +11,7 @@ export type AuthActionResult = { success: true } | { success: false; error: stri export type AuthSessionPayload = { token?: string; user?: AuthUser; - error?: string; + error?: string | ApiErrorDetail; message?: string; }; @@ -27,8 +27,13 @@ export type OnboardingStatusPayload = { hasCompletedOnboarding?: boolean; }; +export type ApiErrorDetail = { + code?: string; + message?: string; +}; + export type ApiErrorPayload = { - error?: string; + error?: string | ApiErrorDetail; message?: string; }; diff --git a/src/components/auth/utils.ts b/src/components/auth/utils.ts index 152a986c47..ccc2cc5811 100644 --- a/src/components/auth/utils.ts +++ b/src/components/auth/utils.ts @@ -13,5 +13,16 @@ export function resolveApiErrorMessage(payload: ApiErrorPayload | null, fallback return fallback; } - return payload.error ?? payload.message ?? fallback; + // The server returns errors as either a plain string or a structured + // `{ code, message }` object. Rendering the raw object as a React child + // crashes the tree, so the message string is always extracted here. + const { error } = payload; + if (typeof error === 'string') { + return error; + } + if (error && typeof error === 'object' && typeof error.message === 'string') { + return error.message; + } + + return payload.message ?? fallback; } diff --git a/src/components/chat/constants/providerEffort.ts b/src/components/chat/constants/providerEffort.ts index 28e26d3581..a70fb1b394 100644 --- a/src/components/chat/constants/providerEffort.ts +++ b/src/components/chat/constants/providerEffort.ts @@ -6,6 +6,7 @@ export const FALLBACK_PROVIDER_EFFORT_VALUES: Partial, +): NormalizedMessage => ({ + id: 'message-1', + sessionId: 'session-1', + timestamp: '2026-08-04T00:00:00.000Z', + provider: 'pi', + kind: 'tool_use', + ...overrides, +}); + +test('Pi top-level realtime tool results attach to their tool calls', () => { + const toolUseResult = { path: 'README.md' }; + const converted = normalizedToChatMessages([ + createMessage({ + id: 'tool-use-top-level', + kind: 'tool_use', + toolId: 'tool-top-level', + toolName: 'read', + toolInput: { path: 'README.md' }, + }), + createMessage({ + id: 'tool-result-top-level', + kind: 'tool_result', + toolId: 'tool-top-level', + content: 'file body', + isError: false, + toolUseResult, + }), + ]); + + assert.equal(converted.length, 1); + assert.equal(converted[0]?.toolResult?.content, 'file body'); + assert.equal(converted[0]?.toolResult?.isError, false); + assert.deepEqual(converted[0]?.toolResult?.toolUseResult, toolUseResult); +}); + +test('Pi nested realtime tool results attach without crashing the conversation', () => { + const converted = normalizedToChatMessages([ + createMessage({ + id: 'tool-use-1', + kind: 'tool_use', + toolId: 'tool-1', + toolName: 'read', + toolInput: { path: 'README.md' }, + }), + createMessage({ + id: 'tool-result-1', + kind: 'tool_result', + toolId: 'tool-1', + toolResult: { content: 'file body', isError: false }, + isError: false, + }), + ]); + + assert.equal(converted.length, 1); + assert.equal(converted[0]?.toolResult?.content, 'file body'); + assert.equal(converted[0]?.toolResult?.isError, false); +}); + +test('a malformed tool result with no content cannot crash the conversation', () => { + const converted = normalizedToChatMessages([ + createMessage({ + id: 'tool-use-2', + kind: 'tool_use', + toolId: 'tool-2', + toolName: 'read', + }), + createMessage({ + id: 'tool-result-2', + kind: 'tool_result', + toolId: 'tool-2', + }), + ]); + + assert.equal(converted.length, 1); + assert.equal(converted[0]?.toolResult?.content, ''); +}); + +test('thinking messages preserve stable identity and streaming metadata for Reasoning', () => { + const converted = normalizedToChatMessages([ + createMessage({ + id: 'thinking-stable-1', + kind: 'thinking', + content: 'considering the request', + isStreaming: true, + duration: 3, + }), + ]); + + assert.equal(converted.length, 1); + assert.equal(converted[0]?.id, 'thinking-stable-1'); + assert.equal(converted[0]?.isThinking, true); + assert.equal(converted[0]?.isStreaming, true); + assert.equal(converted[0]?.duration, 3); +}); diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index 7f120033e4..ef571bd76b 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -8,7 +8,20 @@ import type { ChatMessage, SubagentChildTool } from '../types/types'; import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting'; function formatToolResultContent(content: unknown): string { - const text = typeof content === 'string' ? content : JSON.stringify(content); + let text: string; + if (typeof content === 'string') { + text = content; + } else { + try { + text = JSON.stringify(content) ?? ''; + } catch { + try { + text = String(content ?? ''); + } catch { + text = ''; + } + } + } const toolUseErrorMatch = /^([\s\S]*)<\/tool_use_error>$/.exec(text.trim()); return toolUseErrorMatch ? toolUseErrorMatch[1] : text; } @@ -81,6 +94,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes for (const msg of messages) { const sharedMetadata = { + id: msg.id, displayText: msg.displayText, commandName: msg.commandName, commandMessage: msg.commandMessage, @@ -144,7 +158,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes } case 'tool_use': { - const tr = msg.toolResult || (msg.toolId ? toolResultMap.get(msg.toolId) : null); + const linkedResult = msg.toolId ? toolResultMap.get(msg.toolId) : undefined; + const hasToolResult = msg.toolResult != null || linkedResult != null; const isSubagentContainer = msg.toolName === 'Task'; // Build child tools from subagentTools @@ -161,11 +176,18 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes } } - const toolResult = tr + const toolResult = hasToolResult ? { - content: formatToolResultContent(tr.content), - isError: Boolean(tr.isError), - toolUseResult: (tr as any).toolUseResult, + content: formatToolResultContent( + msg.toolResult?.content ?? linkedResult?.content ?? linkedResult?.toolResult?.content, + ), + isError: Boolean( + msg.toolResult?.isError ?? linkedResult?.isError ?? linkedResult?.toolResult?.isError, + ), + toolUseResult: + msg.toolResult?.toolUseResult ?? + linkedResult?.toolUseResult ?? + linkedResult?.toolResult?.toolUseResult, } : null; @@ -198,6 +220,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes content: unescapeWithMathProtection(msg.content), timestamp: msg.timestamp, isThinking: true, + isStreaming: Boolean(msg.isStreaming), + duration: typeof msg.duration === 'number' ? msg.duration : undefined, ...sharedMetadata, }); } @@ -271,13 +295,13 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes break; } - const content = formatToolResultContent(msg.content || ''); + const content = formatToolResultContent(msg.content ?? msg.toolResult?.content); if (!content.trim()) { break; } converted.push({ - type: msg.isError ? 'error' : 'assistant', + type: (msg.isError ?? msg.toolResult?.isError) ? 'error' : 'assistant', content, timestamp: msg.timestamp, toolId: msg.toolId, diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index cfe83f9c21..e02a577550 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -21,9 +21,10 @@ const FALLBACK_DEFAULT_MODEL: Record = { cursor: 'gpt-5.3-codex', codex: 'gpt-5.4', opencode: 'anthropic/claude-sonnet-4-5', + pi: 'anthropic/claude-sonnet-4-5', }; -const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode']; +const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode', 'pi']; const readStoredProvider = (): LLMProvider => { const storedProvider = localStorage.getItem('selected-provider'); @@ -43,6 +44,7 @@ const FALLBACK_PERMISSION_MODES: Record = { cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], codex: ['default', 'acceptEdits', 'bypassPermissions'], opencode: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + pi: ['bypassPermissions', 'plan'], }; type ProviderCapabilities = { @@ -114,6 +116,9 @@ export function useChatProviderState({ selectedSession, selectedProject: _select const [opencodeModel, setOpenCodeModel] = useState(() => { return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode; }); + const [piModel, setPiModel] = useState(() => { + return localStorage.getItem('pi-model') || FALLBACK_DEFAULT_MODEL.pi; + }); /** * Backend-owned capability matrix keyed by provider. Drives the permission @@ -156,8 +161,14 @@ export function useChatProviderState({ selectedSession, selectedProject: _select return; } - setOpenCodeModel(model); - localStorage.setItem('opencode-model', model); + if (targetProvider === 'opencode') { + setOpenCodeModel(model); + localStorage.setItem('opencode-model', model); + return; + } + + setPiModel(model); + localStorage.setItem('pi-model', model); }, []); const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => { @@ -360,7 +371,8 @@ export function useChatProviderState({ selectedSession, selectedProject: _select cursor: cursorModel, codex: codexModel, opencode: opencodeModel, - }), [claudeModel, cursorModel, codexModel, opencodeModel]); + pi: piModel, + }), [claudeModel, cursorModel, codexModel, opencodeModel, piModel]); useEffect(() => { const claude = providerModelCatalog.claude; @@ -414,6 +426,19 @@ export function useChatProviderState({ selectedSession, selectedProject: _select } }, [providerModelCatalog.opencode, opencodeModel]); + useEffect(() => { + const pi = providerModelCatalog.pi; + if (pi) { + const next = pickStoredOrCurrent('pi-model', piModel, pi); + if (next !== piModel) { + setPiModel(next); + } + if (localStorage.getItem('pi-model') !== next) { + localStorage.setItem('pi-model', next); + } + } + }, [providerModelCatalog.pi, piModel]); + useEffect(() => { const nextEfforts: Partial> = {}; let hasUpdates = false; @@ -618,6 +643,8 @@ export function useChatProviderState({ selectedSession, selectedProject: _select currentProviderModelOptions, opencodeModel, setOpenCodeModel, + piModel, + setPiModel, permissionMode, setPermissionMode, pendingPermissionRequests, diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index cf93c84c30..9f986d625f 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -39,6 +39,7 @@ export interface SubagentChildTool { } export interface ChatMessage { + id?: string; type: string; content?: string; displayText?: string; @@ -48,6 +49,8 @@ export interface ChatMessage { reasoning?: string; isThinking?: boolean; isStreaming?: boolean; + /** Completed reasoning duration in whole seconds. */ + duration?: number; isInteractivePrompt?: boolean; isToolUse?: boolean; toolName?: string; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 50b8e9621f..5063c768c3 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -74,6 +74,8 @@ function ChatInterface({ currentProviderModelOptions, opencodeModel, setOpenCodeModel, + piModel, + setPiModel, permissionMode, pendingPermissionRequests, setPendingPermissionRequests, @@ -309,7 +311,9 @@ function ChatInterface({ ? t('messageTypes.codex') : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) - : t('messageTypes.claude'); + : provider === 'pi' + ? t('messageTypes.pi', { defaultValue: 'Pi' }) + : t('messageTypes.claude'); if (!selectedProject) { return ( @@ -350,6 +354,8 @@ function ChatInterface({ setCodexModel={setCodexModel} opencodeModel={opencodeModel} setOpenCodeModel={setOpenCodeModel} + piModel={piModel} + setPiModel={setPiModel} providerModelCatalog={providerModelCatalog} providerModelsLoading={providerModelsLoading} tasksEnabled={tasksEnabled} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index b9f09a0d9c..151b39fbc5 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -41,6 +41,8 @@ interface ChatMessagesPaneProps { setCodexModel: (model: string) => void; opencodeModel: string; setOpenCodeModel: (model: string) => void; + piModel: string; + setPiModel: (model: string) => void; providerModelCatalog: Partial>; providerModelsLoading: boolean; tasksEnabled: boolean; @@ -89,6 +91,8 @@ function ChatMessagesPane({ setCodexModel, opencodeModel, setOpenCodeModel, + piModel, + setPiModel, providerModelCatalog, providerModelsLoading, tasksEnabled, @@ -195,6 +199,8 @@ function ChatMessagesPane({ setCodexModel={setCodexModel} opencodeModel={opencodeModel} setOpenCodeModel={setOpenCodeModel} + piModel={piModel} + setPiModel={setPiModel} providerModelCatalog={providerModelCatalog} providerModelsLoading={providerModelsLoading} tasksEnabled={tasksEnabled} diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx index 9f97b49368..ff04ecb786 100644 --- a/src/components/chat/view/subcomponents/CommandResultModal.tsx +++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx @@ -61,6 +61,7 @@ const PROVIDER_LABELS: Record = { cursor: 'Cursor', codex: 'Codex', opencode: 'OpenCode', + pi: 'Pi', }; const FALLBACK_COMMANDS: CommandEntry[] = [ diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index f54963be16..733f2685f0 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -165,6 +165,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s ? t('messageTypes.codex') : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) + : provider === 'pi' + ? t('messageTypes.pi', { defaultValue: 'Pi' }) : t('messageTypes.claude'))} @@ -309,7 +311,10 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s ) : message.isThinking ? ( /* Thinking messages — Reasoning component (ai-elements pattern) */ - + @@ -400,4 +405,3 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s }); export default MessageComponent; - diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 6772ae09b9..8413b6a996 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -28,6 +28,7 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [ { id: "codex", name: "OpenAI" }, { id: "cursor", name: "Cursor" }, { id: "opencode", name: "OpenCode" }, + { id: "pi", name: "Pi" }, ]; const MOD_KEY = @@ -58,6 +59,8 @@ type ProviderSelectionEmptyStateProps = { setCodexModel: (model: string) => void; opencodeModel: string; setOpenCodeModel: (model: string) => void; + piModel: string; + setPiModel: (model: string) => void; providerModelCatalog: Partial>; providerModelsLoading: boolean; tasksEnabled: boolean; @@ -86,10 +89,12 @@ function getCurrentModel( cu: string, co: string, o: string, + pi: string, ) { if (p === "claude") return c; if (p === "codex") return co; if (p === "opencode") return o; + if (p === "pi") return pi; return cu; } @@ -98,6 +103,7 @@ function getProviderDisplayName(p: LLMProvider) { if (p === "cursor") return "Cursor"; if (p === "codex") return "Codex"; if (p === "opencode") return "OpenCode"; + if (p === "pi") return "Pi"; return "Claude"; } @@ -115,6 +121,8 @@ export default function ProviderSelectionEmptyState({ setCodexModel, opencodeModel, setOpenCodeModel, + piModel, + setPiModel, providerModelCatalog, providerModelsLoading, tasksEnabled, @@ -143,6 +151,7 @@ export default function ProviderSelectionEmptyState({ cursorModel, codexModel, opencodeModel, + piModel, ); const currentModelLabel = useMemo(() => { @@ -164,12 +173,15 @@ export default function ProviderSelectionEmptyState({ } else if (providerId === "opencode") { setOpenCodeModel(modelValue); localStorage.setItem("opencode-model", modelValue); + } else if (providerId === "pi") { + setPiModel(modelValue); + localStorage.setItem("pi-model", modelValue); } else { setCursorModel(modelValue); localStorage.setItem("cursor-model", modelValue); } }, - [setClaudeModel, setCursorModel, setCodexModel, setOpenCodeModel], + [setClaudeModel, setCursorModel, setCodexModel, setOpenCodeModel, setPiModel], ); const handleModelSelect = useCallback( @@ -316,6 +328,10 @@ export default function ProviderSelectionEmptyState({ model: opencodeModel, defaultValue: "Ready with OpenCode {{model}}", }), + pi: t("providerSelection.readyPrompt.pi", { + model: piModel, + defaultValue: "Ready with Pi {{model}}", + }), }[provider] }

diff --git a/src/components/llm-logo-provider/PiLogo.tsx b/src/components/llm-logo-provider/PiLogo.tsx new file mode 100644 index 0000000000..c90a61ba00 --- /dev/null +++ b/src/components/llm-logo-provider/PiLogo.tsx @@ -0,0 +1,25 @@ +type PiLogoProps = { + className?: string; +}; + +const PiLogo = ({ className = 'w-5 h-5' }: PiLogoProps) => ( + + + + +); + +export default PiLogo; diff --git a/src/components/llm-logo-provider/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx index b93d9eb56e..e597bad0e8 100644 --- a/src/components/llm-logo-provider/SessionProviderLogo.tsx +++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx @@ -3,6 +3,7 @@ import ClaudeLogo from './ClaudeLogo'; import CodexLogo from './CodexLogo'; import CursorLogo from './CursorLogo'; import OpenCodeLogo from './OpenCodeLogo'; +import PiLogo from './PiLogo'; type SessionProviderLogoProps = { provider?: LLMProvider | string | null; @@ -25,5 +26,9 @@ export default function SessionProviderLogo({ return ; } + if (provider === 'pi') { + return ; + } + return ; } diff --git a/src/components/mcp/types.ts b/src/components/mcp/types.ts index 2e3b618dae..8a08fc9270 100644 --- a/src/components/mcp/types.ts +++ b/src/components/mcp/types.ts @@ -1,6 +1,7 @@ import type { LLMProvider } from '../../types/app'; -export type McpProvider = LLMProvider; +// Pi does not support MCP, so it is excluded from the configurable providers. +export type McpProvider = Exclude; export type McpScope = 'user' | 'local' | 'project'; export type McpTransport = 'stdio' | 'http' | 'sse'; export type McpImportMode = 'form' | 'json'; diff --git a/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx b/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx index cef3f94adc..8591f01399 100644 --- a/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx +++ b/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx @@ -1,4 +1,5 @@ import { Check } from 'lucide-react'; + import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import type { LLMProvider } from '../../../../types/app'; import type { ProviderAuthStatus } from '../../../provider-auth/types'; @@ -11,6 +12,7 @@ type AgentConnectionCardProps = { iconContainerClassName: string; loginButtonClassName: string; onLogin: () => void; + hideLogin?: boolean; }; export default function AgentConnectionCard({ @@ -21,6 +23,7 @@ export default function AgentConnectionCard({ iconContainerClassName, loginButtonClassName, onLogin, + hideLogin = false, }: AgentConnectionCardProps) { const containerClassName = status.authenticated ? connectedClassName : 'border-border bg-card'; @@ -47,7 +50,7 @@ export default function AgentConnectionCard({ - {!status.authenticated && !status.loading && ( + {!hideLogin && !status.authenticated && !status.loading && (