From 25ecc3c77b33dfdb2c295d3022c0fd8dc3c8b273 Mon Sep 17 00:00:00 2001
From: KeenJoe
Date: Fri, 31 Jul 2026 18:09:20 +0800
Subject: [PATCH 1/7] docs: add TODO list
Co-Authored-By: Claude Opus 4.8 (1M context)
---
TODO.md | 9 +++++++++
1 file changed, 9 insertions(+)
create mode 100644 TODO.md
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 要展示正确的用户信息
From d9d3c12770dfc83a3e7ab937f640a0ac60949992 Mon Sep 17 00:00:00 2001
From: KeenJoe
Date: Fri, 31 Jul 2026 18:09:51 +0800
Subject: [PATCH 2/7] feat: prefer ANTHROPIC_MODEL for claude runtime, add pi
integration plan
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.gitignore | 8 +-
docs/pi-provider-integration-plan.md | 95 +++++++++++++++++++
.../list/claude/claude-runtime.provider.js | 8 +-
3 files changed, 106 insertions(+), 5 deletions(-)
create mode 100644 docs/pi-provider-integration-plan.md
diff --git a/.gitignore b/.gitignore
index 34be0c9bca..3e5a0b49cf 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,7 @@ tasks/
cloudcli-sidebar-app-source.tar.gz
cloudcli-sidebar.html
electron/*.tar.gz
+
+# Task files
+# tasks.json
+# tasks/
diff --git a/docs/pi-provider-integration-plan.md b/docs/pi-provider-integration-plan.md
new file mode 100644
index 0000000000..3c49318288
--- /dev/null
+++ b/docs/pi-provider-integration-plan.md
@@ -0,0 +1,95 @@
+# pi Agent 接入方案
+
+将 [earendil-works/pi](https://github.com/earendil-works/pi) 作为第 5 个 provider 接入本项目(现有:claude / codex / cursor / opencode)。
+
+## 决策(已确认)
+
+- **取舍 1 — 权限模式:方案 A。** `plan` → `--tools read,grep,find,ls`(只读工具集);其余三态全工具开。代价:`acceptEdits` 与 `bypassPermissions` 在 pi 下行为相同,`default` 无「逐条确认」能力(语义降级)。
+- **取舍 2 — 模型目录:方案 A。** 运行时读 `~/.pi/agent/models.json` 动态生成,覆盖 pi 挂载的全部 LLM。
+- **取舍 3 — session id:方案 A。** 用 `--session-id ` 直接以 app 自身 session id 建/续 pi 会话,绕开 provider id 映射往返。
+
+## 第一性原理
+
+provider 抽象把「一个 AI CLI」拆成 7 个正交 facet(`IProvider`)。接入 = 为 7 个 facet 各写一个 pi 实现,再在 3 处 union + runner wiring 登记。opencode 是最新范本,pi 照抄结构。
+
+## 已核实事实(方案据此成立)
+
+- pi 本地已装:`/opt/homebrew/bin/pi`。
+- headless:`pi -p --mode json`,输出 **NDJSON** 事件流(逐行 JSON)。
+- 事件类型:`session` / `agent_start` / `turn_start` / `message_start` / `message_update`(含 `assistantMessageEvent`:`thinking_start|text_start|text_end` 等 delta) / `message_end` / `turn_end`(含 `usage`) / `agent_end` / `agent_settled`。
+- 会话续接:`--session-id `(不存在即创建)/ `--continue` / `--resume` / `--fork` / `--session-dir`。
+- effort:`--thinking off|minimal|low|medium|high|xhigh|max`,与 UI effort 直接对应。
+- 模型:`--provider --model `;目录来源 `~/.pi/agent/models.json`(`providers..models[]`,字段含 `id/name/reasoning/contextWindow/maxTokens`)。
+- 会话磁盘格式:`~/.pi/agent/sessions//_.jsonl`,每行一个事件,`type:"message"` 行含 `message.role/content[]`、assistant 行含 `usage`。
+- config 目录:`PI_CODING_AGENT_DIR`(默认 `~/.pi/agent`);session 目录:`PI_CODING_AGENT_SESSION_DIR` 或 `--session-dir`。
+- 落差:无内置权限系统(仅工具白/黑名单);MCP 非标准(走 extensions 机制)。
+
+## 落差与对策
+
+| 契约 | pi 对应 | 对策 |
+|---|---|---|
+| 权限模式 | 无权限系统,仅工具开关 | 取舍 1A:plan=只读工具集,其余全开 |
+| MCP | 非标准(extensions) | `pi-mcp` 空实现,返回空列表、写操作报不支持 |
+| 模型目录 | 多 LLM 前端 | 取舍 2A:读 models.json 动态生成 |
+
+## 实现清单
+
+### 新增 `server/modules/providers/list/pi/`(照 opencode 结构)
+
+- `pi.provider.ts` — 组装 7 facet,`super('pi')`(~30 行)。
+- `pi-runtime.provider.js` — **核心**。spawn `pi -p --mode json --session-id --session-dir <项目slug> [--provider/--model/--thinking/--tools]`,用 cross-spawn;按行解析 NDJSON:
+ - `message_update.assistantMessageEvent` 的 thinking/text delta → `writer.send` stream delta;
+ - `turn_end.usage` → token 用量;
+ - `agent_end` / 进程退出 → complete;进程失败 → `notifyRunFailed`,abort → `notifyRunStopped`。
+ - 维护 `activePiProcesses: Map`,`abort` 发 SIGTERM。
+ - 权限:由 `options.permissionMode` 经 `resolvePiPermissionOptions()` 映射为 `--tools` 参数(取舍 1A)。
+ - 导出 `{ run, abort }` 作为 `piRuntime`。(对标 opencode ~350 行)
+- `pi-sessions.provider.ts` — `normalizeMessage(raw, sessionId)`(NDJSON 事件 → `NormalizedMessage[]`)+ `fetchHistory(sessionId, opts)`(读对应 `.jsonl`,projectPath→cwd-slug 定位目录)。
+- `pi-session-synchronizer.provider.ts` — `synchronize(since)` 扫描 `~/.pi/agent/sessions/**/*.jsonl` upsert 入库;`synchronizeFile(path)` 单文件增量。
+- `pi-models.provider.ts` — 读 `models.json`,`getSupportedModels()` 生成目录(含 `--thinking` 档位作为 effort),`getCurrentActiveModel()` 读会话首行 `model_change` 事件,缺省回落目录默认。
+- `pi-auth.provider.ts` — `getStatus()`:`pi` 在 PATH(`which pi` / claude-cli-path 同款探测)+ `~/.pi/agent/auth.json` 或 provider apiKey 存在 → `{ installed, authenticated }`,不抛异常。
+- `pi-skills.provider.ts` — 读/写 `~/.pi/agent/skills`(照 opencode skills,~78 行)。
+- `pi-mcp.provider.ts` — 空实现:`listServers`→`[]`,`upsert/remove`→抛「pi 不支持 MCP」。
+
+### 权限映射函数(写在 pi-runtime,导出供测试)
+
+```
+plan → { args: ['--tools', 'read,grep,find,ls'] }
+acceptEdits → { args: [] } // 全工具开
+bypassPermissions → { args: [] } // 同上(语义降级,取舍 1A)
+default → { args: [] }
+```
+
+### 登记(后端,各约 1 行)
+
+- `server/shared/types.ts:69` — `LLMProvider` union 加 `'pi'`。
+- `server/modules/providers/provider.registry.ts` — import `PiProvider` + `pi: new PiProvider()`。
+- `server/index.ts` — `const queryPi = providerRuntimeService.getRunner('pi');` 并传入 `createAgentModule({ ..., queryPi })`。
+- `createAgentModule` 签名(`server/modules/agent/agent.module.ts`)增加 `queryPi` 形参并接线。
+- 确认 sessions-watcher(`services/sessions-watcher.service.ts`)监听 `~/.pi/agent/sessions`。
+
+### 前端(各约 1 行 + 1 图标)
+
+- `src/types/app.ts:1` — `LLMProvider` union 加 `'pi'`。
+- `src/components/settings/constants/constants.ts:42` — `AGENT_PROVIDERS` 加 `'pi'`。
+- 新增 `src/components/llm-logo-provider/PiLogo.tsx`,并在 `SessionProviderLogo.tsx` 映射 `pi → PiLogo`。
+- 权限降级提示:在权限模式选择处,当 provider==='pi' 时对 `default` 加提示「pi 无逐条确认,等同自动放行」,`acceptEdits`/`bypassPermissions` 说明行为一致(可选,UI 打磨项)。
+
+## 工作量
+
+核心风险与工时集中在 `pi-runtime`(NDJSON 归一化 + 权限映射)、`pi-sessions`、`pi-session-synchronizer`、`pi-models` 四个文件(对标 opencode 约 1200 行);其余为模板与登记。
+
+## 验证
+
+1. `pi-auth` 未装/未登录返回正确状态,不抛异常。
+2. headless 一问一答:NDJSON delta 正确流式到前端。
+3. `--session-id` 续接:第二轮命中同一会话文件。
+4. `plan` 模式下 pi 无法写文件(只读工具集生效)。
+5. synchronizer 扫描后 DB 出现 pi 历史会话,可在 UI 打开。
+6. `abort` 能杀掉进程并发 stopped 通知。
+7. `npm run typecheck` + `npm run lint` + `npm test` 通过。
+
+## 未决 / 后续
+
+- pi extensions ↔ 本项目 MCP/skills 的更深映射(本期空实现)。
+- 多 LLM provider 的 apiKey 管理是否纳入 provider-auth UI(本期仅探测存在性)。
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,
From a7b284cf9f58bc0e930e04130e7350061825eb5d Mon Sep 17 00:00:00 2001
From: KeenJoe
Date: Fri, 31 Jul 2026 21:59:31 +0800
Subject: [PATCH 3/7] fix: resolve shell PATH and login crash issues
- shell: append user-level bin dirs (~/.local/bin etc.) to PATH in spawned
pty so CLIs like claude are reachable when npm strips shell-rc PATH entries
- auth: handle structured error object {code, message} in
resolveApiErrorMessage to prevent React crash (white screen) on login failure
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../services/shell-websocket.service.ts | 47 +++++++++++++++++++
src/components/auth/types.ts | 9 +++-
src/components/auth/utils.ts | 13 ++++-
3 files changed, 66 insertions(+), 3 deletions(-)
diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts
index 7d0013608c..646ea8aa07 100644
--- a/server/modules/websocket/services/shell-websocket.service.ts
+++ b/server/modules/websocket/services/shell-websocket.service.ts
@@ -282,6 +282,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 +444,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',
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;
}
From 8b02ed613e473b04c9eb1fc09ffde1fea7f9fced Mon Sep 17 00:00:00 2001
From: KeenJoe
Date: Fri, 31 Jul 2026 22:58:23 +0800
Subject: [PATCH 4/7] docs: revise pi provider integration architecture
---
docs/pi-provider-integration-plan.md | 1104 ++++++++++++++++++++++++--
1 file changed, 1036 insertions(+), 68 deletions(-)
diff --git a/docs/pi-provider-integration-plan.md b/docs/pi-provider-integration-plan.md
index 3c49318288..284d35aed6 100644
--- a/docs/pi-provider-integration-plan.md
+++ b/docs/pi-provider-integration-plan.md
@@ -1,95 +1,1063 @@
-# pi Agent 接入方案
+# Pi Provider 集成方案(架构修订版)
-将 [earendil-works/pi](https://github.com/earendil-works/pi) 作为第 5 个 provider 接入本项目(现有:claude / codex / cursor / opencode)。
+> 状态:待实施
+>
+> 基线:基于当前仓库已有的 provider、WebSocket、Agent API、session、models、auth、skills、MCP 和 token usage 行为制定。
+>
+> 原则:只解决当前已经存在的功能契约及其架构问题,不为尚未存在的产品功能预留抽象。
-## 决策(已确认)
+## 1. 结论
-- **取舍 1 — 权限模式:方案 A。** `plan` → `--tools read,grep,find,ls`(只读工具集);其余三态全工具开。代价:`acceptEdits` 与 `bypassPermissions` 在 pi 下行为相同,`default` 无「逐条确认」能力(语义降级)。
-- **取舍 2 — 模型目录:方案 A。** 运行时读 `~/.pi/agent/models.json` 动态生成,覆盖 pi 挂载的全部 LLM。
-- **取舍 3 — session id:方案 A。** 用 `--session-id ` 直接以 app 自身 session id 建/续 pi 会话,绕开 provider id 映射往返。
+Pi 不应作为“照抄 OpenCode 的第 5 套 adapter + 若干中央分支”接入。
-## 第一性原理
+最终方案分成两部分:
-provider 抽象把「一个 AI CLI」拆成 7 个正交 facet(`IProvider`)。接入 = 为 7 个 facet 各写一个 pi 实现,再在 3 处 union + runner wiring 登记。opencode 是最新范本,pi 照抄结构。
+1. 先修正 provider 公共接缝,使 registry 成为 provider 描述、能力和 facet 的唯一真相。
+2. 再通过 Pi 自带的 RPC 协议和共享 `PiSessionStore` 实现 Pi provider。
-## 已核实事实(方案据此成立)
+必须保留当前已经正确工作的 application 层机制:
-- pi 本地已装:`/opt/homebrew/bin/pi`。
-- headless:`pi -p --mode json`,输出 **NDJSON** 事件流(逐行 JSON)。
-- 事件类型:`session` / `agent_start` / `turn_start` / `message_start` / `message_update`(含 `assistantMessageEvent`:`thinking_start|text_start|text_end` 等 delta) / `message_end` / `turn_end`(含 `usage`) / `agent_end` / `agent_settled`。
-- 会话续接:`--session-id `(不存在即创建)/ `--continue` / `--resume` / `--fork` / `--session-dir`。
-- effort:`--thinking off|minimal|low|medium|high|xhigh|max`,与 UI effort 直接对应。
-- 模型:`--provider --model `;目录来源 `~/.pi/agent/models.json`(`providers..models[]`,字段含 `id/name/reasoning/contextWindow/maxTokens`)。
-- 会话磁盘格式:`~/.pi/agent/sessions//_.jsonl`,每行一个事件,`type:"message"` 行含 `message.role/content[]`、assistant 行含 `usage`。
-- config 目录:`PI_CODING_AGENT_DIR`(默认 `~/.pi/agent`);session 目录:`PI_CODING_AGENT_SESSION_DIR` 或 `--session-dir`。
-- 落差:无内置权限系统(仅工具白/黑名单);MCP 非标准(走 extensions 机制)。
+- app session ID 与 provider-native session ID 分离。
+- `ChatSessionWriter` 捕获并持久化 session mapping。
+- `ChatRunRegistry` 保证一个 session 同时只有一个 run。
+- live event 使用单调 `seq`,支持 reconnect replay。
+- frontend 永远只接触 app session ID。
+- duplicate `complete` 被防御性丢弃。
-## 落差与对策
+必须修正的核心问题:
-| 契约 | pi 对应 | 对策 |
+- 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 必须修正的模块
+
+| 问题 | 当前形态 | 目标形态 |
|---|---|---|
-| 权限模式 | 无权限系统,仅工具开关 | 取舍 1A:plan=只读工具集,其余全开 |
-| MCP | 非标准(extensions) | `pi-mcp` 空实现,返回空列表、写操作报不支持 |
-| 模型目录 | 多 LLM 前端 | 取舍 2A:读 models.json 动态生成 |
+| 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 和数据库
-### 新增 `server/modules/providers/list/pi/`(照 opencode 结构)
+### 8.1 组合唯一性
-- `pi.provider.ts` — 组装 7 facet,`super('pi')`(~30 行)。
-- `pi-runtime.provider.js` — **核心**。spawn `pi -p --mode json --session-id --session-dir <项目slug> [--provider/--model/--thinking/--tools]`,用 cross-spawn;按行解析 NDJSON:
- - `message_update.assistantMessageEvent` 的 thinking/text delta → `writer.send` stream delta;
- - `turn_end.usage` → token 用量;
- - `agent_end` / 进程退出 → complete;进程失败 → `notifyRunFailed`,abort → `notifyRunStopped`。
- - 维护 `activePiProcesses: Map`,`abort` 发 SIGTERM。
- - 权限:由 `options.permissionMode` 经 `resolvePiPermissionOptions()` 映射为 `--tools` 参数(取舍 1A)。
- - 导出 `{ run, abort }` 作为 `piRuntime`。(对标 opencode ~350 行)
-- `pi-sessions.provider.ts` — `normalizeMessage(raw, sessionId)`(NDJSON 事件 → `NormalizedMessage[]`)+ `fetchHistory(sessionId, opts)`(读对应 `.jsonl`,projectPath→cwd-slug 定位目录)。
-- `pi-session-synchronizer.provider.ts` — `synchronize(since)` 扫描 `~/.pi/agent/sessions/**/*.jsonl` upsert 入库;`synchronizeFile(path)` 单文件增量。
-- `pi-models.provider.ts` — 读 `models.json`,`getSupportedModels()` 生成目录(含 `--thinking` 档位作为 effort),`getCurrentActiveModel()` 读会话首行 `model_change` 事件,缺省回落目录默认。
-- `pi-auth.provider.ts` — `getStatus()`:`pi` 在 PATH(`which pi` / claude-cli-path 同款探测)+ `~/.pi/agent/auth.json` 或 provider apiKey 存在 → `{ installed, authenticated }`,不抛异常。
-- `pi-skills.provider.ts` — 读/写 `~/.pi/agent/skills`(照 opencode skills,~78 行)。
-- `pi-mcp.provider.ts` — 空实现:`listServers`→`[]`,`upsert/remove`→抛「pi 不支持 MCP」。
+增加 migration:
-### 权限映射函数(写在 pi-runtime,导出供测试)
+```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
```
-plan → { args: ['--tools', 'read,grep,find,ls'] }
-acceptEdits → { args: [] } // 全工具开
-bypassPermissions → { args: [] } // 同上(语义降级,取舍 1A)
-default → { args: [] }
+
+失败状态:
+
+```text
+any non-terminal state
+ -> ABORTING -> ABORTED
+ -> FAILED
```
-### 登记(后端,各约 1 行)
+执行顺序:
+
+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`。
-- `server/shared/types.ts:69` — `LLMProvider` union 加 `'pi'`。
-- `server/modules/providers/provider.registry.ts` — import `PiProvider` + `pi: new PiProvider()`。
-- `server/index.ts` — `const queryPi = providerRuntimeService.getRunner('pi');` 并传入 `createAgentModule({ ..., queryPi })`。
-- `createAgentModule` 签名(`server/modules/agent/agent.module.ts`)增加 `queryPi` 形参并接线。
-- 确认 sessions-watcher(`services/sessions-watcher.service.ts`)监听 `~/.pi/agent/sessions`。
+## 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
+:
+```
-### 前端(各约 1 行 + 1 图标)
+分页继续使用现有 `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
+```
-- `src/types/app.ts:1` — `LLMProvider` union 加 `'pi'`。
-- `src/components/settings/constants/constants.ts:42` — `AGENT_PROVIDERS` 加 `'pi'`。
-- 新增 `src/components/llm-logo-provider/PiLogo.tsx`,并在 `SessionProviderLogo.tsx` 映射 `pi → PiLogo`。
-- 权限降级提示:在权限模式选择处,当 provider==='pi' 时对 `default` 加提示「pi 无逐条确认,等同自动放行」,`acceptEdits`/`bypassPermissions` 说明行为一致(可选,UI 打磨项)。
+真实 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。
-核心风险与工时集中在 `pi-runtime`(NDJSON 归一化 + 权限映射)、`pi-sessions`、`pi-session-synchronizer`、`pi-models` 四个文件(对标 opencode 约 1200 行);其余为模板与登记。
+## 25. 完成定义
-## 验证
+只有同时满足以下条件,Pi provider 才算接入完成:
-1. `pi-auth` 未装/未登录返回正确状态,不抛异常。
-2. headless 一问一答:NDJSON delta 正确流式到前端。
-3. `--session-id` 续接:第二轮命中同一会话文件。
-4. `plan` 模式下 pi 无法写文件(只读工具集生效)。
-5. synchronizer 扫描后 DB 出现 pi 历史会话,可在 UI 打开。
-6. `abort` 能杀掉进程并发 stopped 通知。
-7. `npm run typecheck` + `npm run lint` + `npm test` 通过。
+- 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 extensions ↔ 本项目 MCP/skills 的更深映射(本期空实现)。
-- 多 LLM provider 的 apiKey 管理是否纳入 provider-auth UI(本期仅探测存在性)。
+| 风险 | 控制 |
+|---|---|
+| 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 |
From 3331861fe1c29a2ddeb9bb0aca9c33c41e1610a0 Mon Sep 17 00:00:00 2001
From: KeenJoe
Date: Mon, 3 Aug 2026 11:01:04 +0800
Subject: [PATCH 5/7] docs: add collaboration context and paseo provider switch
docs
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.gitignore | 3 +
CONTEXT.md | 29 ++
docs/paseo-agent-switching-analysis.md | 565 +++++++++++++++++++++++
docs/paseo-provider-switch-design.md | 613 +++++++++++++++++++++++++
4 files changed, 1210 insertions(+)
create mode 100644 CONTEXT.md
create mode 100644 docs/paseo-agent-switching-analysis.md
create mode 100644 docs/paseo-provider-switch-design.md
diff --git a/.gitignore b/.gitignore
index 3e5a0b49cf..eba8d344ef 100755
--- a/.gitignore
+++ b/.gitignore
@@ -151,3 +151,6 @@ 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/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 映射。
From 11fc0152e0acd2bf6a260a7e8009a44f5367554d Mon Sep 17 00:00:00 2001
From: KeenJoe
Date: Mon, 3 Aug 2026 16:39:56 +0800
Subject: [PATCH 6/7] feat: integrate Pi coding agent as a provider
Add Pi as a full provider alongside the existing four, wired through the
same IProvider contract: live chat runtime (RPC mode, agent_settled as the
sole success terminal), model catalog, install/auth status, session history
and disk sync, skills discovery, and permission modes. Unsupported
capabilities (MCP) surface an explicit ERR rather than empty success.
- Pin @earendil-works/pi-coding-agent@0.83.0 (exact version)
- New list/pi facets: paths, rpc-client, session-store, runtime, models,
auth, sessions, session-synchronizer, skills, token-usage, mcp
- Central wiring: registry, capabilities, token-usage, watcher, agent routes
- Frontend: pi provider type, brand/logo, per-provider model state,
permission picker, MCP exclusion, /skill: display
- Fix runtime close-before-settle detection via real process exit hook
- Skip MCP-unsupported providers in global add/remove instead of reporting
a spurious failure
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../changes/add-pi-provider/.openspec.yaml | 2 +
openspec/changes/add-pi-provider/design.md | 149 ++
openspec/changes/add-pi-provider/proposal.md | 31 +
.../add-pi-provider/specs/pi-provider/spec.md | 173 ++
openspec/changes/add-pi-provider/tasks.md | 61 +
.../add-pi-provider/test-definition.md | 118 ++
.../refactor-provider-seams/.openspec.yaml | 2 +
.../changes/refactor-provider-seams/design.md | 140 ++
.../refactor-provider-seams/proposal.md | 34 +
.../specs/provider-seams/spec.md | 87 +
.../changes/refactor-provider-seams/tasks.md | 68 +
.../test-definition.md | 84 +
openspec/config.yaml | 20 +
package-lock.json | 1831 ++++++++++++++++-
package.json | 1 +
server/index.ts | 2 +
server/modules/agent/agent.module.ts | 2 +-
server/modules/agent/agent.routes.ts | 18 +-
.../modules/agent/tests/agent.routes.test.ts | 32 +
server/modules/providers/list/pi/index.ts | 7 +
.../list/pi/pi-auth.provider.test.ts | 114 +
.../providers/list/pi/pi-auth.provider.ts | 109 +
.../providers/list/pi/pi-mcp.provider.test.ts | 50 +
.../providers/list/pi/pi-mcp.provider.ts | 51 +
.../list/pi/pi-models.provider.test.ts | 94 +
.../providers/list/pi/pi-models.provider.ts | 101 +
.../list/pi/pi-paths.provider.test.ts | 138 ++
.../providers/list/pi/pi-paths.provider.ts | 86 +
.../list/pi/pi-rpc-client.provider.test.ts | 221 ++
.../list/pi/pi-rpc-client.provider.ts | 178 ++
.../list/pi/pi-runtime.provider.test.ts | 419 ++++
.../providers/list/pi/pi-runtime.provider.ts | 413 ++++
.../list/pi/pi-session-store.provider.test.ts | 189 ++
.../list/pi/pi-session-store.provider.ts | 283 +++
.../pi-session-synchronizer.provider.test.ts | 137 ++
.../pi/pi-session-synchronizer.provider.ts | 101 +
.../list/pi/pi-sessions.provider.test.ts | 130 ++
.../providers/list/pi/pi-sessions.provider.ts | 149 ++
.../list/pi/pi-skills.provider.test.ts | 118 ++
.../providers/list/pi/pi-skills.provider.ts | 86 +
.../list/pi/pi-token-usage.provider.test.ts | 46 +
.../list/pi/pi-token-usage.provider.ts | 57 +
.../providers/list/pi/pi.provider.test.ts | 25 +
.../modules/providers/list/pi/pi.provider.ts | 70 +
server/modules/providers/provider.registry.ts | 2 +
.../modules/providers/services/mcp.service.ts | 6 +
.../services/provider-capabilities.service.ts | 11 +
.../services/provider-token-usage.service.ts | 23 +
.../services/session-synchronizer.service.ts | 1 +
.../services/sessions-watcher.service.ts | 7 +-
server/modules/providers/tests/mcp.test.ts | 1 +
.../provider-capabilities.service.test.ts | 23 +
.../providers/tests/provider-registry.test.ts | 33 +
.../tests/provider-runtime.service.test.ts | 1 +
.../provider-token-usage.service.test.ts | 98 +
.../tests/sessions-watcher-paths.test.ts | 19 +
server/shared/types.ts | 2 +-
.../chat/constants/providerEffort.ts | 1 +
.../chat/hooks/useChatComposerState.ts | 4 +-
.../chat/hooks/useChatProviderState.ts | 35 +-
src/components/chat/view/ChatInterface.tsx | 8 +-
.../view/subcomponents/ChatMessagesPane.tsx | 6 +
.../view/subcomponents/CommandResultModal.tsx | 1 +
.../view/subcomponents/MessageComponent.tsx | 2 +
.../ProviderSelectionEmptyState.tsx | 18 +-
src/components/llm-logo-provider/PiLogo.tsx | 25 +
.../llm-logo-provider/SessionProviderLogo.tsx | 5 +
src/components/mcp/types.ts | 3 +-
.../subcomponents/AgentConnectionsStep.tsx | 7 +
src/components/provider-auth/types.ts | 4 +-
.../provider-auth/view/ProviderLoginModal.tsx | 5 +
.../settings/constants/constants.ts | 2 +-
.../tabs/agents-settings/AgentListItem.tsx | 4 +
.../agents-settings/AgentsSettingsTab.tsx | 19 +-
.../sections/AgentCategoryContentSection.tsx | 2 +-
.../sections/AgentSelectorSection.tsx | 1 +
.../sections/content/AccountContent.tsx | 9 +
src/components/skills/view/ProviderSkills.tsx | 7 +-
src/i18n/locales/en/chat.json | 4 +-
src/i18n/locales/en/settings.json | 3 +
src/i18n/locales/fr/chat.json | 4 +-
src/i18n/locales/fr/settings.json | 3 +
src/types/app.ts | 2 +-
83 files changed, 6601 insertions(+), 37 deletions(-)
create mode 100644 openspec/changes/add-pi-provider/.openspec.yaml
create mode 100644 openspec/changes/add-pi-provider/design.md
create mode 100644 openspec/changes/add-pi-provider/proposal.md
create mode 100644 openspec/changes/add-pi-provider/specs/pi-provider/spec.md
create mode 100644 openspec/changes/add-pi-provider/tasks.md
create mode 100644 openspec/changes/add-pi-provider/test-definition.md
create mode 100644 openspec/changes/refactor-provider-seams/.openspec.yaml
create mode 100644 openspec/changes/refactor-provider-seams/design.md
create mode 100644 openspec/changes/refactor-provider-seams/proposal.md
create mode 100644 openspec/changes/refactor-provider-seams/specs/provider-seams/spec.md
create mode 100644 openspec/changes/refactor-provider-seams/tasks.md
create mode 100644 openspec/changes/refactor-provider-seams/test-definition.md
create mode 100644 openspec/config.yaml
create mode 100644 server/modules/providers/list/pi/index.ts
create mode 100644 server/modules/providers/list/pi/pi-auth.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-auth.provider.ts
create mode 100644 server/modules/providers/list/pi/pi-mcp.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-mcp.provider.ts
create mode 100644 server/modules/providers/list/pi/pi-models.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-models.provider.ts
create mode 100644 server/modules/providers/list/pi/pi-paths.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-paths.provider.ts
create mode 100644 server/modules/providers/list/pi/pi-rpc-client.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-rpc-client.provider.ts
create mode 100644 server/modules/providers/list/pi/pi-runtime.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-runtime.provider.ts
create mode 100644 server/modules/providers/list/pi/pi-session-store.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-session-store.provider.ts
create mode 100644 server/modules/providers/list/pi/pi-session-synchronizer.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-session-synchronizer.provider.ts
create mode 100644 server/modules/providers/list/pi/pi-sessions.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-sessions.provider.ts
create mode 100644 server/modules/providers/list/pi/pi-skills.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-skills.provider.ts
create mode 100644 server/modules/providers/list/pi/pi-token-usage.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi-token-usage.provider.ts
create mode 100644 server/modules/providers/list/pi/pi.provider.test.ts
create mode 100644 server/modules/providers/list/pi/pi.provider.ts
create mode 100644 server/modules/providers/tests/provider-capabilities.service.test.ts
create mode 100644 server/modules/providers/tests/provider-registry.test.ts
create mode 100644 server/modules/providers/tests/sessions-watcher-paths.test.ts
create mode 100644 src/components/llm-logo-provider/PiLogo.tsx
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..fbaa86a62d 100644
--- a/package.json
+++ b/package.json
@@ -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..df5003a9c5 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();
@@ -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/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..4e362ab920
--- /dev/null
+++ b/server/modules/providers/list/pi/pi-auth.provider.ts
@@ -0,0 +1,109 @@
+/**
+ * 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 {
+ const client = this.deps.createRpcClient({ cliPath: this.deps.paths.getCliPath() });
+ 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..1d7a2e3dd9
--- /dev/null
+++ b/server/modules/providers/list/pi/pi-paths.provider.test.ts
@@ -0,0 +1,138 @@
+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',
+] 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('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..2a29124f20
--- /dev/null
+++ b/server/modules/providers/list/pi/pi-paths.provider.ts
@@ -0,0 +1,86 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+/**
+ * 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);
+ }
+
+ /**
+ * 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..cdace15b33
--- /dev/null
+++ b/server/modules/providers/list/pi/pi-rpc-client.provider.test.ts
@@ -0,0 +1,221 @@
+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 --mode rpc --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, ['--mode', 'rpc', '--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, ['--mode', 'rpc', '--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);
+});
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..e7a02fafd0
--- /dev/null
+++ b/server/modules/providers/list/pi/pi-rpc-client.provider.ts
@@ -0,0 +1,178 @@
+/**
+ * PiRpcClient - thin wrapper around the official Pi rpc-client.
+ *
+ * Spawns `pi --mode rpc --no-extensions` (via the official RpcClient) and adds
+ * a small layer the runtime relies on:
+ * - fixed args injection (--mode rpc --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';
+
+/**
+ * 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;
+}
+
+const FIXED_ARGS = ['--mode', 'rpc', '--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, ...rest } = this.options;
+ const client = this.deps.createClient({
+ ...rest,
+ args: [...FIXED_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;
+
+ let timer: NodeJS.Timeout | undefined;
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(resolve, graceMs);
+ });
+ const stopped = 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..eb291f045d
--- /dev/null
+++ b/server/modules/providers/list/pi/pi-runtime.provider.test.ts
@@ -0,0 +1,419 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+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 {
+ kind: string;
+ content?: string;
+ code?: string;
+ success?: boolean;
+ aborted?: boolean;
+ 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_delta and thinking_delta to normalized stream events', () => {
+ 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_delta', delta: 'ponder' },
+ }),
+ { kind: 'thinking', content: 'ponder' },
+ );
+});
+
+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', toolResult: { content: 'ok', isError: false }, isError: false },
+ );
+ assert.equal(mapPiEvent({ type: 'turn_end', turnIndex: 0 })?.status, 'turn_end');
+ assert.equal(
+ mapPiEvent({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 10, errorMessage: 'x' })?.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_delta', delta: '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(thinking.length, 1);
+ assert.equal(completes.length, 1);
+ assert.equal(completes[0].success, true);
+});
+
+// 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..69789ddc84
--- /dev/null
+++ b/server/modules/providers/list/pi/pi-runtime.provider.ts
@@ -0,0 +1,413 @@
+/**
+ * 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;
+
+/** Runtime states (progress markers; terminal handling is guarded separately). */
+export type PiRuntimeState =
+ | 'SPAWNING'
+ | 'REQUESTING_STATE'
+ | 'BINDING_SESSION'
+ | 'PROMPTING'
+ | 'STREAMING'
+ | 'SETTLED';
+
+/** Normalized event produced by {@link mapPiEvent} (transport-agnostic). */
+export interface NormalizedPiEvent {
+ kind: 'stream_delta' | 'thinking' | 'tool_use' | 'tool_result' | 'status';
+ content?: string;
+ toolId?: string;
+ toolName?: string;
+ toolInput?: unknown;
+ toolResult?: { content?: string; isError?: boolean };
+ isError?: boolean;
+ status?: string;
+}
+
+/** 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' });
+}
+
+/**
+ * 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_delta') {
+ if (typeof inner.delta !== 'string') {
+ throw protocolError('thinking_delta missing string delta');
+ }
+ return { kind: 'thinking', content: inner.delta };
+ }
+ 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,
+ toolResult: {
+ content: typeof event.result === 'string' ? event.result : undefined,
+ isError,
+ },
+ 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';
+}
+
+export interface PiRuntimeDeps {
+ createRpcClient?: CreatePiRuntimeRpc;
+ abortGraceMs?: 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 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;
+
+ let state: PiRuntimeState = 'SPAWNING';
+ let settled = false;
+ let aborting = false;
+ let boundSessionId: string | null = appSessionId;
+ let firstLiveEventSent = false;
+
+ const rpc = createRpcClient({
+ cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
+ });
+
+ return new Promise((resolve) => {
+ let abortTimer: NodeJS.Timeout | undefined;
+
+ const finish = (outcome: PiRunOutcome): void => {
+ if (settled) return;
+ 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,
+ }),
+ );
+ }
+ 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 }));
+ }, 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;
+ }
+
+ 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;
+
+ const existing = context.resolveProviderSessionId(appSessionId);
+ 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 (!existing && !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..a398ed06c0
--- /dev/null
+++ b/server/modules/providers/list/pi/pi-sessions.provider.test.ts
@@ -0,0 +1,130 @@
+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; 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, 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 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');
+});
+
+// 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..fd0a9096aa
--- /dev/null
+++ b/server/modules/providers/list/pi/pi-sessions.provider.ts
@@ -0,0 +1,149 @@
+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;
+}
+
+/**
+ * Extracts ordered text content blocks from a Pi message. A string `content`
+ * yields a single block; an array yields one block per `{ type: 'text' }`
+ * element. The returned index is the block's position within `content` so the
+ * message id stays stable across reads.
+ */
+function extractContentBlocks(message: Record): Array<{ index: number; text: string }> {
+ const content = message.content;
+
+ if (typeof content === 'string') {
+ return [{ index: 0, text: content }];
+ }
+
+ if (Array.isArray(content)) {
+ const blocks: Array<{ index: number; text: string }> = [];
+ 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, text: record.text });
+ }
+ }
+ });
+ return blocks;
+ }
+
+ return [];
+}
+
+/**
+ * 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 = typeof message.timestamp === 'string' ? message.timestamp : undefined;
+ const normalizedRole = toRole(role);
+
+ return extractContentBlocks(message).map(({ index, text }) => createNormalizedMessage({
+ id: `${entryId}:${index}`,
+ sessionId: sessionId ?? '',
+ timestamp,
+ provider: PROVIDER,
+ kind: 'text',
+ role: normalizedRole,
+ content: text,
+ }));
+ }
+
+ async fetchHistory(
+ sessionId: string,
+ options: FetchHistoryOptions = {},
+ ): Promise {
+ const { limit = null, offset = 0 } = options;
+ const providerSessionId = options.providerSessionId ?? sessionId;
+
+ const filePath = 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..05fd3c0687
--- /dev/null
+++ b/server/modules/providers/list/pi/pi.provider.ts
@@ -0,0 +1,70 @@
+/**
+ * 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 { PiPaths } from './pi-paths.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({ cliPath: new PiPaths().getCliPath() });
+ 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/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/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/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/shared/types.ts b/server/shared/types.ts
index f8db6b400b..96caabcdb4 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.
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 = {
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/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..27769e0fd4 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'))}
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/AgentConnectionsStep.tsx b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx
index d9148a9559..dcb0fa9d93 100644
--- a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx
+++ b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx
@@ -37,6 +37,13 @@ const providerCards = [
iconContainerClassName: 'bg-zinc-100 dark:bg-zinc-800',
loginButtonClassName: 'bg-zinc-800 hover:bg-zinc-900 dark:bg-zinc-700 dark:hover:bg-zinc-600',
},
+ {
+ provider: 'pi' as const,
+ title: 'Pi',
+ connectedClassName: 'bg-zinc-100 dark:bg-zinc-800/50 border-zinc-300 dark:border-zinc-600',
+ iconContainerClassName: 'bg-zinc-100 dark:bg-zinc-800',
+ loginButtonClassName: 'bg-zinc-800 hover:bg-zinc-900 dark:bg-zinc-700 dark:hover:bg-zinc-600',
+ },
];
export default function AgentConnectionsStep({
diff --git a/src/components/provider-auth/types.ts b/src/components/provider-auth/types.ts
index 5511c1848f..4a1ffbb48a 100644
--- a/src/components/provider-auth/types.ts
+++ b/src/components/provider-auth/types.ts
@@ -10,13 +10,14 @@ export type ProviderAuthStatus = {
export type ProviderAuthStatusMap = Record;
-export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode'];
+export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode', 'pi'];
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = {
claude: '/api/providers/claude/auth/status',
cursor: '/api/providers/cursor/auth/status',
codex: '/api/providers/codex/auth/status',
opencode: '/api/providers/opencode/auth/status',
+ pi: '/api/providers/pi/auth/status',
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
@@ -24,4 +25,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth
cursor: { authenticated: false, email: null, method: null, error: null, loading },
codex: { authenticated: false, email: null, method: null, error: null, loading },
opencode: { authenticated: false, email: null, method: null, error: null, loading },
+ pi: { authenticated: false, email: null, method: null, error: null, loading },
});
diff --git a/src/components/provider-auth/view/ProviderLoginModal.tsx b/src/components/provider-auth/view/ProviderLoginModal.tsx
index 2a0cfc2ae0..338fe299f9 100644
--- a/src/components/provider-auth/view/ProviderLoginModal.tsx
+++ b/src/components/provider-auth/view/ProviderLoginModal.tsx
@@ -41,6 +41,10 @@ const getProviderCommand = ({
return 'opencode auth login';
}
+ if (provider === 'pi') {
+ return 'pi auth login';
+ }
+
return 'claude --dangerously-skip-permissions /login';
};
@@ -49,6 +53,7 @@ const getProviderTitle = (provider: LLMProvider) => {
if (provider === 'cursor') return 'Cursor CLI Login';
if (provider === 'codex') return 'Codex CLI Login';
if (provider === 'opencode') return 'OpenCode CLI Login';
+ if (provider === 'pi') return 'Pi CLI Login';
return 'Claude CLI Login';
};
diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts
index 8c429083df..3cd3dc2d62 100644
--- a/src/components/settings/constants/constants.ts
+++ b/src/components/settings/constants/constants.ts
@@ -39,7 +39,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
];
-export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'opencode'];
+export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'opencode', 'pi'];
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
diff --git a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
index 52e87c3272..02f0a6a744 100644
--- a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
+++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
@@ -32,6 +32,10 @@ const agentConfig: Record = {
name: 'OpenCode',
color: 'zinc',
},
+ pi: {
+ name: 'Pi',
+ color: 'zinc',
+ },
};
const colorClasses = {
diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
index 31216a103b..ffd4a0c556 100644
--- a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
+++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
@@ -20,14 +20,18 @@ export default function AgentsSettingsTab({
}: AgentsSettingsTabProps) {
const [selectedAgent, setSelectedAgent] = useState('claude');
const [selectedCategory, setSelectedCategory] = useState('account');
- const visibleCategories = useMemo(() => (
- selectedAgent === 'opencode'
+ const visibleCategories = useMemo(() => {
+ // Pi does not support MCP, so the MCP tab is hidden for it.
+ if (selectedAgent === 'pi') {
+ return ['account', 'permissions', 'skills'];
+ }
+ return selectedAgent === 'opencode'
? ['account', 'permissions', 'mcp']
- : ['account', 'permissions', 'mcp', 'skills']
- ), [selectedAgent]);
+ : ['account', 'permissions', 'mcp', 'skills'];
+ }, [selectedAgent]);
const visibleAgents = useMemo(() => {
- return ['claude', 'cursor', 'codex', 'opencode'];
+ return ['claude', 'cursor', 'codex', 'opencode', 'pi'];
}, []);
const agentContextById = useMemo>(() => ({
@@ -47,12 +51,17 @@ export default function AgentsSettingsTab({
authStatus: providerAuthStatus.opencode,
onLogin: () => onProviderLogin('opencode'),
},
+ pi: {
+ authStatus: providerAuthStatus.pi,
+ onLogin: () => onProviderLogin('pi'),
+ },
}), [
onProviderLogin,
providerAuthStatus.claude,
providerAuthStatus.codex,
providerAuthStatus.cursor,
providerAuthStatus.opencode,
+ providerAuthStatus.pi,
]);
useEffect(() => {
diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx
index 5bf0de2a0b..a485a76237 100644
--- a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx
+++ b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx
@@ -73,7 +73,7 @@ export default function AgentCategoryContentSection({
/>
)}
- {selectedCategory === 'mcp' && (
+ {selectedCategory === 'mcp' && selectedAgent !== 'pi' && (
// SettingsProject.name is populated from the DB projectId by
// normalizeProjectForSettings, so we can map it straight through.
= {
cursor: 'Cursor',
codex: 'Codex',
opencode: 'OpenCode',
+ pi: 'Pi',
};
export default function AgentSelectorSection({
diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx
index 83fbb24910..38b153de44 100644
--- a/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx
+++ b/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx
@@ -54,6 +54,15 @@ const agentConfig: Record = {
subtextClass: 'text-zinc-700 dark:text-zinc-300',
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
},
+ pi: {
+ name: 'Pi',
+ description: 'Pi CLI assistant',
+ bgClass: 'bg-zinc-50 dark:bg-zinc-900/20',
+ borderClass: 'border-zinc-200 dark:border-zinc-700',
+ textClass: 'text-zinc-900 dark:text-zinc-100',
+ subtextClass: 'text-zinc-700 dark:text-zinc-300',
+ buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
+ },
};
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
diff --git a/src/components/skills/view/ProviderSkills.tsx b/src/components/skills/view/ProviderSkills.tsx
index 908ed07625..f1a16e9f67 100644
--- a/src/components/skills/view/ProviderSkills.tsx
+++ b/src/components/skills/view/ProviderSkills.tsx
@@ -60,9 +60,10 @@ const PROVIDER_NAMES: Record = {
codex: 'Codex',
cursor: 'Cursor',
opencode: 'OpenCode',
+ pi: 'Pi',
};
-const PROVIDER_SKILL_PATHS: Record, string> = {
+const PROVIDER_SKILL_PATHS: Record, string> = {
claude: '~/.claude/skills//SKILL.md',
codex: '~/.agents/skills//SKILL.md',
cursor: '~/.cursor/skills//SKILL.md',
@@ -220,7 +221,9 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
const folderInputRef = useRef(null);
const providerName = PROVIDER_NAMES[selectedProvider];
- const providerPath = selectedProvider === 'opencode' ? null : PROVIDER_SKILL_PATHS[selectedProvider];
+ const providerPath = selectedProvider === 'opencode' || selectedProvider === 'pi'
+ ? null
+ : PROVIDER_SKILL_PATHS[selectedProvider];
useEffect(() => {
setQueuedFiles([]);
diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json
index eaecb479aa..2821929682 100644
--- a/src/i18n/locales/en/chat.json
+++ b/src/i18n/locales/en/chat.json
@@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
- "opencode": "OpenCode"
+ "opencode": "OpenCode",
+ "pi": "Pi"
},
"tools": {
"settings": "Tool Settings",
@@ -161,6 +162,7 @@
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
"opencode": "Ready to use OpenCode with {{model}}. Start typing your message below.",
+ "pi": "Ready to use Pi with {{model}}. Start typing your message below.",
"default": "Select a provider above to begin"
},
"pressToSearch": "Press {{shortcut}} to search sessions, files, and commits"
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 305ce574b2..b20f762ff8 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -351,6 +351,9 @@
},
"opencode": {
"description": "OpenCode CLI assistant"
+ },
+ "pi": {
+ "description": "Pi CLI assistant"
}
},
"connectionStatus": "Connection Status",
diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json
index 89dcb7b321..467f5f05e7 100644
--- a/src/i18n/locales/fr/chat.json
+++ b/src/i18n/locales/fr/chat.json
@@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
- "opencode": "OpenCode"
+ "opencode": "OpenCode",
+ "pi": "Pi"
},
"tools": {
"settings": "Paramètres de l'outil",
@@ -135,6 +136,7 @@
"cursor": "Prêt à utiliser Cursor avec {{model}}. Commencez à taper votre message ci-dessous.",
"codex": "Prêt à utiliser Codex avec {{model}}. Commencez à taper votre message ci-dessous.",
"opencode": "Prêt à utiliser OpenCode avec {{model}}. Commencez à taper votre message ci-dessous.",
+ "pi": "Prêt à utiliser Pi avec {{model}}. Commencez à taper votre message ci-dessous.",
"default": "Sélectionnez un fournisseur ci-dessus pour commencer"
},
"pressToSearch": "Appuyez sur {{shortcut}} pour rechercher sessions, fichiers et commits"
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index bf8994ed60..bc322a595d 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -325,6 +325,9 @@
},
"opencode": {
"description": "Assistant CLI OpenCode"
+ },
+ "pi": {
+ "description": "Assistant CLI Pi"
}
},
"connectionStatus": "Statut de la connexion",
diff --git a/src/types/app.ts b/src/types/app.ts
index 8fa4a68430..e7214c83a3 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -1,4 +1,4 @@
-export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'opencode';
+export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'pi';
export type ProviderModelOption = {
value: string;
From 6c5089bc19f908157d7039e48d54aef2943b8bfd Mon Sep 17 00:00:00 2001
From: KeenJoe
Date: Tue, 4 Aug 2026 14:24:22 +0800
Subject: [PATCH 7/7] fix: stabilize Pi agent integration
---
package.json | 2 +-
server/modules/agent/agent.routes.ts | 2 +-
server/modules/commands/commands.routes.ts | 3 +-
.../modules/commands/tests/commands.test.ts | 11 +
.../providers/list/pi/pi-auth.provider.ts | 6 +-
.../list/pi/pi-paths.provider.test.ts | 23 ++
.../providers/list/pi/pi-paths.provider.ts | 26 ++
.../list/pi/pi-rpc-client.provider.test.ts | 20 +-
.../list/pi/pi-rpc-client.provider.ts | 28 +-
.../list/pi/pi-runtime.provider.test.ts | 244 ++++++++++++++-
.../providers/list/pi/pi-runtime.provider.ts | 289 ++++++++++++++++--
.../list/pi/pi-sessions.provider.test.ts | 85 +++++-
.../providers/list/pi/pi-sessions.provider.ts | 50 ++-
.../modules/providers/list/pi/pi.provider.ts | 3 +-
server/modules/providers/provider.routes.ts | 15 +-
.../providers/services/sessions.service.ts | 1 +
.../providers/tests/provider.routes.test.ts | 47 +++
.../services/shell-websocket.service.ts | 9 +
.../tests/shell-websocket.service.test.ts | 31 ++
server/shared/types.ts | 13 +
.../chat/hooks/useChatMessages.test.ts | 103 +++++++
src/components/chat/hooks/useChatMessages.ts | 40 ++-
src/components/chat/types/types.ts | 3 +
.../view/subcomponents/MessageComponent.tsx | 6 +-
.../subcomponents/AgentConnectionCard.tsx | 5 +-
.../subcomponents/AgentConnectionsStep.tsx | 15 +-
.../hooks/useProviderAuthStatus.ts | 30 +-
.../provider-auth/view/ProviderLoginModal.tsx | 5 +-
.../sections/content/AccountContent.tsx | 3 +-
.../sessionMessageReconciliation.test.ts | 55 +++-
src/stores/sessionMessageReconciliation.ts | 38 +++
src/stores/useSessionStore.ts | 67 +++-
32 files changed, 1172 insertions(+), 106 deletions(-)
create mode 100644 server/modules/providers/tests/provider.routes.test.ts
create mode 100644 src/components/chat/hooks/useChatMessages.test.ts
diff --git a/package.json b/package.json
index fbaa86a62d..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",
diff --git a/server/modules/agent/agent.routes.ts b/server/modules/agent/agent.routes.ts
index df5003a9c5..7c24a3e05d 100644
--- a/server/modules/agent/agent.routes.ts
+++ b/server/modules/agent/agent.routes.ts
@@ -664,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.
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/pi/pi-auth.provider.ts b/server/modules/providers/list/pi/pi-auth.provider.ts
index 4e362ab920..070ac40b7f 100644
--- a/server/modules/providers/list/pi/pi-auth.provider.ts
+++ b/server/modules/providers/list/pi/pi-auth.provider.ts
@@ -9,7 +9,6 @@
* 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';
@@ -91,7 +90,10 @@ export class PiAuthProvider implements IProviderAuth {
* model is returned. Any failure is treated as "not authenticated".
*/
private async probeAuthenticated(): Promise {
- const client = this.deps.createRpcClient({ cliPath: this.deps.paths.getCliPath() });
+ // 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();
diff --git a/server/modules/providers/list/pi/pi-paths.provider.test.ts b/server/modules/providers/list/pi/pi-paths.provider.test.ts
index 1d7a2e3dd9..0c3c74143b 100644
--- a/server/modules/providers/list/pi/pi-paths.provider.test.ts
+++ b/server/modules/providers/list/pi/pi-paths.provider.test.ts
@@ -10,6 +10,7 @@ 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 {
@@ -54,6 +55,28 @@ test('getCliPath resolves PI_CLI_PATH', () => {
});
});
+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(
diff --git a/server/modules/providers/list/pi/pi-paths.provider.ts b/server/modules/providers/list/pi/pi-paths.provider.ts
index 2a29124f20..057a07b5d3 100644
--- a/server/modules/providers/list/pi/pi-paths.provider.ts
+++ b/server/modules/providers/list/pi/pi-paths.provider.ts
@@ -1,6 +1,7 @@
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
@@ -26,6 +27,31 @@ export class PiPaths {
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`.
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
index cdace15b33..5532034b8b 100644
--- a/server/modules/providers/list/pi/pi-rpc-client.provider.test.ts
+++ b/server/modules/providers/list/pi/pi-rpc-client.provider.test.ts
@@ -89,7 +89,7 @@ const makeClient = (fake: FakeUnderlyingClient, options: Record
return { client, captured };
};
-test('start injects --mode rpc --no-extensions and merges caller options', async () => {
+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' } });
@@ -97,7 +97,7 @@ test('start injects --mode rpc --no-extensions and merges caller options', async
assert.equal(fake.startCalls, 1);
const args = (captured.options?.args as string[]) ?? [];
- assert.deepEqual(args, ['--mode', 'rpc', '--no-extensions']);
+ assert.deepEqual(args, ['--no-extensions']);
assert.equal(captured.options?.cwd, '/tmp/work');
assert.deepEqual(captured.options?.env, { FOO: 'bar' });
});
@@ -108,7 +108,7 @@ test('start merges caller args after the fixed args', async () => {
await client.start();
- assert.deepEqual(captured.options?.args, ['--mode', 'rpc', '--no-extensions', '--extra']);
+ assert.deepEqual(captured.options?.args, ['--no-extensions', '--extra']);
});
test('onEvent forwards events in dispatch order', async () => {
@@ -219,3 +219,17 @@ test('onClose fires when the underlying process exits', async () => {
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
index e7a02fafd0..0e7ec6f6e5 100644
--- a/server/modules/providers/list/pi/pi-rpc-client.provider.ts
+++ b/server/modules/providers/list/pi/pi-rpc-client.provider.ts
@@ -1,9 +1,9 @@
/**
* PiRpcClient - thin wrapper around the official Pi rpc-client.
*
- * Spawns `pi --mode rpc --no-extensions` (via the official RpcClient) and adds
+ * Spawns Pi in RPC mode with extensions disabled (via the official RpcClient) and adds
* a small layer the runtime relies on:
- * - fixed args injection (--mode rpc --no-extensions), merged with caller args,
+ * - 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.
@@ -22,6 +22,8 @@ import {
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
@@ -62,7 +64,8 @@ export interface PiRpcClientDeps {
createClient(options: RpcClientOptions): UnderlyingRpcClient;
}
-const FIXED_ARGS = ['--mode', 'rpc', '--no-extensions'];
+// 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 = {
@@ -107,10 +110,14 @@ export class PiRpcClient {
}
async start(): Promise {
- const { args, ...rest } = this.options;
+ const { args, cliPath, ...rest } = this.options;
const client = this.deps.createClient({
...rest,
- args: [...FIXED_ARGS, ...(args ?? [])],
+ // 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();
@@ -157,15 +164,18 @@ export class PiRpcClient {
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 = client.stop().then(
- () => undefined,
- () => undefined,
- );
+ const stopped = Promise.resolve()
+ .then(() => client.stop())
+ .then(
+ () => undefined,
+ () => undefined,
+ );
await Promise.race([stopped, timeout]);
if (timer) clearTimeout(timer);
diff --git a/server/modules/providers/list/pi/pi-runtime.provider.test.ts b/server/modules/providers/list/pi/pi-runtime.provider.test.ts
index eb291f045d..faa428367f 100644
--- a/server/modules/providers/list/pi/pi-runtime.provider.test.ts
+++ b/server/modules/providers/list/pi/pi-runtime.provider.test.ts
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
+import type { RpcClientOptions } from '@earendil-works/pi-coding-agent';
+
import {
createPiRuntime,
mapPiEvent,
@@ -73,11 +75,15 @@ class FakeRpc implements PiRuntimeRpc {
}
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;
@@ -113,7 +119,7 @@ const tick = () => new Promise((resolve) => setImmediate(resolve));
// Pure event mapping (T1 stream shape, T3 protocol, T4 unknown)
// ---------------------------------------------------------------------------
-test('mapPiEvent maps text_delta and thinking_delta to normalized stream events', () => {
+test('mapPiEvent maps text and thinking lifecycle events without losing block identity', () => {
assert.deepEqual(
mapPiEvent({
type: 'message_update',
@@ -124,9 +130,23 @@ test('mapPiEvent maps text_delta and thinking_delta to normalized stream events'
assert.deepEqual(
mapPiEvent({
type: 'message_update',
- assistantMessageEvent: { type: 'thinking_delta', delta: 'ponder' },
+ assistantMessageEvent: { type: 'thinking_start', contentIndex: 2 },
}),
- { kind: 'thinking', content: 'ponder' },
+ { 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' },
);
});
@@ -137,12 +157,85 @@ test('mapPiEvent maps tool execution start/end and retry/turn_end to status', ()
);
assert.deepEqual(
mapPiEvent({ type: 'tool_execution_end', toolCallId: 't1', toolName: 'bash', result: 'ok', isError: false }),
- { kind: 'tool_result', toolId: 't1', toolName: 'bash', toolResult: { content: 'ok', isError: false }, 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.equal(mapPiEvent({ type: 'turn_end', turnIndex: 0 })?.status, 'turn_end');
- assert.equal(
- mapPiEvent({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 10, errorMessage: 'x' })?.status,
- 'retry',
+ 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' },
);
});
@@ -185,7 +278,9 @@ test('T1: streams normalized text/thinking then completes once on agent_settled'
await tick();
fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'Hel' } });
- fake.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', delta: 'hmm' } });
+ 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;
@@ -196,11 +291,140 @@ test('T1: streams normalized text/thinking then completes once on agent_settled'
const completes = sent.filter((m) => m.kind === 'complete');
assert.equal(streamDeltas.length, 1);
assert.equal(streamDeltas[0].content, 'Hel');
- assert.equal(thinking.length, 1);
+ 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();
diff --git a/server/modules/providers/list/pi/pi-runtime.provider.ts b/server/modules/providers/list/pi/pi-runtime.provider.ts
index 69789ddc84..1f2e83316d 100644
--- a/server/modules/providers/list/pi/pi-runtime.provider.ts
+++ b/server/modules/providers/list/pi/pi-runtime.provider.ts
@@ -29,6 +29,8 @@ 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 =
@@ -39,17 +41,30 @@ export type PiRuntimeState =
| 'STREAMING'
| 'SETTLED';
-/** Normalized event produced by {@link mapPiEvent} (transport-agnostic). */
-export interface NormalizedPiEvent {
- kind: 'stream_delta' | 'thinking' | 'tool_use' | 'tool_result' | 'status';
- content?: string;
- toolId?: string;
- toolName?: string;
- toolInput?: unknown;
- toolResult?: { content?: string; isError?: boolean };
- isError?: boolean;
- status?: string;
-}
+/**
+ * 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 {
@@ -98,6 +113,43 @@ 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.
*
@@ -124,11 +176,39 @@ export function mapPiEvent(event: unknown): NormalizedPiEvent | null {
}
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 (typeof inner.delta !== 'string') {
- throw protocolError('thinking_delta missing string 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', content: inner.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;
}
@@ -154,10 +234,7 @@ export function mapPiEvent(event: unknown): NormalizedPiEvent | null {
kind: 'tool_result',
toolId: event.toolCallId,
toolName: event.toolName,
- toolResult: {
- content: typeof event.result === 'string' ? event.result : undefined,
- isError,
- },
+ content: formatPiToolResultContent(event.result),
isError,
};
}
@@ -186,9 +263,42 @@ 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;
}
/**
@@ -198,6 +308,7 @@ export interface PiRuntimeDeps {
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(
@@ -210,22 +321,118 @@ export function createPiRuntime(deps: PiRuntimeDeps = {}) {
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({
- cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
- });
+ const rpc = createRpcClient(buildRpcClientOptions(options, requestedNativeSessionId));
return new Promise((resolve) => {
let abortTimer: NodeJS.Timeout | undefined;
- const finish = (outcome: PiRunOutcome): void => {
+ 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);
@@ -266,7 +473,11 @@ export function createPiRuntime(deps: PiRuntimeDeps = {}) {
}),
);
}
- resolve(outcome);
+ if (closeRpc) {
+ void rpc.close(RUN_CLOSE_GRACE_MS).finally(() => resolve(outcome));
+ } else {
+ resolve(outcome);
+ }
};
const beginAbort = (): void => {
@@ -276,7 +487,9 @@ export function createPiRuntime(deps: PiRuntimeDeps = {}) {
// 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 }));
+ void rpc.close(0).finally(() => {
+ finish({ status: 'aborted', sessionId: boundSessionId }, false);
+ });
}, abortGraceMs);
};
@@ -323,6 +536,31 @@ export function createPiRuntime(deps: PiRuntimeDeps = {}) {
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(
@@ -373,14 +611,13 @@ export function createPiRuntime(deps: PiRuntimeDeps = {}) {
const nativeId = typeof rpcState?.sessionId === 'string' ? rpcState.sessionId : null;
if (!nativeId) return;
- const existing = context.resolveProviderSessionId(appSessionId);
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 (!existing && !firstLiveEventSent) {
+ if (!existingNativeSessionId && !firstLiveEventSent) {
writer.send(
createNormalizedMessage({
kind: 'session_created',
diff --git a/server/modules/providers/list/pi/pi-sessions.provider.test.ts b/server/modules/providers/list/pi/pi-sessions.provider.test.ts
index a398ed06c0..b170e99ca1 100644
--- a/server/modules/providers/list/pi/pi-sessions.provider.test.ts
+++ b/server/modules/providers/list/pi/pi-sessions.provider.test.ts
@@ -14,7 +14,10 @@ after(() => {
});
let counter = 0;
-function writeSession(lines: string[], { trailingNewline = true } = {}): { sessionId: string; provider: PiSessionsProvider } {
+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`);
@@ -24,7 +27,7 @@ function writeSession(lines: string[], { trailingNewline = true } = {}): { sessi
process.env.PI_CODING_AGENT_SESSION_DIR = root;
const provider = new PiSessionsProvider(new PiPaths());
- return { sessionId, provider };
+ return { sessionId, filePath: file, provider };
}
function header(): string {
@@ -64,6 +67,26 @@ function assistantEntry(id: string, parentId: string): string {
});
}
+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',
@@ -96,6 +119,64 @@ test('T14 有效 v3 session 归一化 active branch history', async () => {
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(
diff --git a/server/modules/providers/list/pi/pi-sessions.provider.ts b/server/modules/providers/list/pi/pi-sessions.provider.ts
index fd0a9096aa..1dda568bb2 100644
--- a/server/modules/providers/list/pi/pi-sessions.provider.ts
+++ b/server/modules/providers/list/pi/pi-sessions.provider.ts
@@ -39,26 +39,32 @@ 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 text content blocks from a Pi message. A string `content`
- * yields a single block; an array yields one block per `{ type: 'text' }`
- * element. The returned index is the block's position within `content` so the
- * message id stays stable across reads.
+ * 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): Array<{ index: number; text: string }> {
+function extractContentBlocks(message: Record): PiDisplayContentBlock[] {
const content = message.content;
if (typeof content === 'string') {
- return [{ index: 0, text: content }];
+ return [{ index: 0, kind: 'text', content }];
}
if (Array.isArray(content)) {
- const blocks: Array<{ index: number; text: string }> = [];
+ 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, text: record.text });
+ 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 });
}
}
});
@@ -68,6 +74,19 @@ function extractContentBlocks(message: Record): Array<{ index:
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.
@@ -85,17 +104,17 @@ export class PiSessionsProvider implements IProviderSessions {
}
const { entryId, role, message } = raw;
- const timestamp = typeof message.timestamp === 'string' ? message.timestamp : undefined;
+ const timestamp = normalizeMessageTimestamp(message.timestamp);
const normalizedRole = toRole(role);
- return extractContentBlocks(message).map(({ index, text }) => createNormalizedMessage({
+ return extractContentBlocks(message).map(({ index, kind, content }) => createNormalizedMessage({
id: `${entryId}:${index}`,
sessionId: sessionId ?? '',
timestamp,
provider: PROVIDER,
- kind: 'text',
- role: normalizedRole,
- content: text,
+ kind,
+ content,
+ ...(kind === 'text' ? { role: normalizedRole } : {}),
}));
}
@@ -106,7 +125,10 @@ export class PiSessionsProvider implements IProviderSessions {
const { limit = null, offset = 0 } = options;
const providerSessionId = options.providerSessionId ?? sessionId;
- const filePath = this.resolveSessionFile(providerSessionId);
+ 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 };
}
diff --git a/server/modules/providers/list/pi/pi.provider.ts b/server/modules/providers/list/pi/pi.provider.ts
index 05fd3c0687..2079514fbd 100644
--- a/server/modules/providers/list/pi/pi.provider.ts
+++ b/server/modules/providers/list/pi/pi.provider.ts
@@ -26,7 +26,6 @@ import {
type PiModelsRpc,
} from './pi-models.provider.js';
import { PiMcpProvider } from './pi-mcp.provider.js';
-import { PiPaths } from './pi-paths.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';
@@ -41,7 +40,7 @@ const MODELS_PROBE_GRACE_MS = 5000;
*/
const piModelsRpc: PiModelsRpc = {
async withProbe(fn) {
- const client = new PiRpcClient({ cliPath: new PiPaths().getCliPath() });
+ const client = new PiRpcClient();
await client.start();
try {
return await fn(client as unknown as PiModelsProbe);
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/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/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/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts
index 646ea8aa07..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') {
@@ -583,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 96caabcdb4..64326c5f62 100644
--- a/server/shared/types.ts
+++ b/server/shared/types.ts
@@ -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/chat/hooks/useChatMessages.test.ts b/src/components/chat/hooks/useChatMessages.test.ts
new file mode 100644
index 0000000000..a2a2545537
--- /dev/null
+++ b/src/components/chat/hooks/useChatMessages.test.ts
@@ -0,0 +1,103 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import type { NormalizedMessage } from '../../../stores/useSessionStore';
+
+import { normalizedToChatMessages } from './useChatMessages';
+
+const createMessage = (
+ overrides: 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/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/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx
index 27769e0fd4..733f2685f0 100644
--- a/src/components/chat/view/subcomponents/MessageComponent.tsx
+++ b/src/components/chat/view/subcomponents/MessageComponent.tsx
@@ -311,7 +311,10 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
) : message.isThinking ? (
/* Thinking messages — Reasoning component (ai-elements pattern) */
-
+
@@ -402,4 +405,3 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
});
export default MessageComponent;
-
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 && (