Merge pull request #5659 from deepseek-harness/worktree/pi-compat-notes-20261003

docs(agent-notes): propose Pi extensions bridge and Pi plugin support levels
This commit is contained in:
Tianyi Cui authored and GitHub committed 2026-10-04 22:35:30 +08:00
commit 3d0e274859
24 files changed
+885 -83

No files matched your search

@@ -0,0 +1,43 @@
# Bilingual-pair consistency record for 2026-10-03-pi-extensions-bridge.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.md
/agent-note-pi-extensions-bridge:
en: cd9f68956ba34672
zh: 1fc649c5eb0d3cf4
/agent-note-pi-extensions-bridge/problem:
en: 9bfc5f0c9ff223a6
zh: 7c5f026c94677dd8
/agent-note-pi-extensions-bridge/proposal:
en: 56b36193ca6f829d
zh: a31e226794c1ca90
/agent-note-pi-extensions-bridge/proposal/packages-and-composition:
en: 2ab10852b93ad41b
zh: 3c14d410235fcfac
/agent-note-pi-extensions-bridge/proposal/loading-and-execution:
en: d95ece799bc6bab7
zh: 1e19d3b49473a57e
/agent-note-pi-extensions-bridge/proposal/serving-the-api:
en: b1365f16d76fe27a
zh: 105b88f80eb8964d
/agent-note-pi-extensions-bridge/proposal/built-in-tool-calls-as-pi-sees-them:
en: c74e4662ad0a8ee7
zh: 653729b41e983b13
/agent-note-pi-extensions-bridge/proposal/unserved-features:
en: 70c74cf93526cfad
zh: cab3efc2c109ad5a
/agent-note-pi-extensions-bridge/proposal/extension-state:
en: be58f498b8d28548
zh: 4cc057f794c288bf
/agent-note-pi-extensions-bridge/proposal/core-mechanisms-narrower-than-pi-s:
en: 9fa8297436332064
zh: 9a000e157940b0f8
/agent-note-pi-extensions-bridge/alternatives-considered:
en: a3769b960278c17f
zh: e7d86ff1aff243b8
/agent-note-pi-extensions-bridge/acceptance-criteria:
en: 60894db423a54ec3
zh: 13b435271f6a1371
/agent-note-pi-extensions-bridge/risks:
en: 2b9a626b13c604f6
zh: f67cb80d2b699989
@@ -0,0 +1,142 @@
# Agent Note: Pi extensions bridge
Status: proposed
English | [中文](2026-10-03-pi-extensions-bridge.zh.md)
## Problem
Pi 1.0.0 extensions are in-process TypeScript factories `(pi: ExtensionAPI) => void`. A factory subscribes to about forty lifecycle, context, tool, and provider events, registers model tools, slash commands, model providers, and MCP servers, and talks to the user through `ctx.ui` ([extensions guide](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/extensions.md), [API types](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/src/core/extensions/types.ts)). The API is published and widely used: about 11,000 npm packages carried the `pi-package` keyword in October 2026.
DSH has bridges for two other ecosystems: shell hooks written for Claude Code and Codex ([hooks group](../../../../packages/hooks/README.md)) and Claude Code mods ([compatibility page](../../../../docs/subsystems/claude-code-mods.md)). Pi's extension API is broader than either. It lets one in-process module rewrite the messages of a single request, edit tool arguments, append its own durable records, and continue a finished run. Serving that API from DSH extension points answers two questions: how much of an extension written for another harness runs unchanged, and which DSH core mechanisms are narrower than Pi's.
## Proposal
Add an experimental bridge that loads unmodified Pi extension sources and serves Pi's `ExtensionAPI` from existing DSH extension points. The bridge is a capability demonstration: it runs extensions as written where an existing DSH mechanism carries the behavior, leaves the rest unserved, and lists every difference on a compatibility page. Apart from the restricted plugin records that extension state needs (row 9 of the gap table), each core gap is closed by a separate change.
### Packages and composition
- `@deepseek-ai/dsh-experimental-pi-extensions` is the Host package and provides `ctx.piExtensions`. `@deepseek-ai/dsh-experimental-client-ui-pi-extensions` is the Web package. Neither is an optional bundle; an opt-in overlay composes them, as for the Claude Code mods bridge.
- One cordis.yml row names one extension source: a file, a directory with an `index.ts` or `index.js`, or a Pi package directory whose `package.json` lists `pi.extensions`. The row also carries the extension's flag values. Row order is Pi's load order, which is also its handler dispatch order.
- The bridge does not scan `~/.pi` or `.pi`, install packages, or decide project trust. A Pi package's skills, prompt templates, and themes are not loaded.
### Loading and execution
- The bridge has its own loader and event dispatcher. `@earendil-works/pi-coding-agent` is not a dependency.
- `jiti` imports the source, so TypeScript loads in a built installation. The loader resolves the imports Pi supplies to extensions: `typebox` and `@earendil-works/pi-ai` to the bridge's own dependencies, `@earendil-works/pi-coding-agent` to a module of the bridge that implements the helpers that need no Pi runtime (`defineTool`, the tool-event type guards, the truncation helpers, `withFileMutationQueue`, `getAgentDir`), and `@earendil-works/pi-tui` to the optional peer dependency when the deployment installed it. An extension that imports a module the loader cannot resolve fails to load with an error naming the module.
- Extension code runs in the Host process with the process's full authority, as mods do.
- Every Session with an in-process Agent gets its own runtime: the module is evaluated once per row, and its factory runs once per Session, as Pi re-runs factories for each Session. State an extension keeps at module scope is shared by all Sessions of the Host process; Pi also keeps one module instance across the Sessions of a process, but runs one Session at a time. A subagent's Session gets a separate runtime in Pi's `print` mode with `hasUI: false`, so a tool call a subagent makes passes the same extension handlers as the root's.
### Serving the API
| Pi API | DSH mechanism |
|---|---|
| `session_start`, `session_shutdown` | `agent/created` (awaited before the first turn), `agent/disposed` |
| `input` (after inbox admission; see row 7), `before_agent_start` | `system-prompt/assemble` and `agent/pre-step`: claimed user messages are transformed or dropped, a returned `systemPrompt` replaces the assembled prompt for that turn, and returned messages join the step |
| `tool_call` | `tools/pre-execute`: `block` becomes a denial; a handler that throws denies the call |
| `tool_result` | `tools/post-execute`: replaced content is installed as the result's content |
| `agent_before_settle` | `agent/turn-stopping`: returned custom entries are recorded, and a returned message with `continue` is steered into the turn |
| `agent_start`, `agent_end`, `agent_settled`, `turn_start`, `turn_end`, `message_start`, `message_end`, `tool_execution_start`, `tool_execution_end`, `model_select`, `session_info_changed`, `session_compact` | Session events and `agent/status`, delivered in order as notifications |
| `registerTool`, `ctx.executeTool` | Agent-scoped `ctx.tools` registrations; nested calls run the full tool pipeline; built-in names follow the translation below |
| `registerCommand`, `getCommands` | Agent-scoped `ctx.commands` |
| `sendUserMessage`, `sendMessage` | `agent.followup()`, `agent.steer()`, `agent.inject()` |
| `appendEntry`, `setLabel`, tool `details`, `ctx.sessionManager` | Ignorable Session records and `tool/result` metadata, read back as Pi entries |
| `getActiveTools`, `setActiveTools`, `getAllTools` | `ctx.tools.schemas()`; `setActiveTools` applies a scoped `ctx.tools.restrict()` to global tools and adds or removes the bridge's own Agent-scoped registrations, because a restriction does not hide scoped tools |
| `setModel`, `getThinkingLevel`, `setThinkingLevel` | An `agent/request` override |
| `exec` | `ctx.subprocess`, argv without a shell |
| `ctx.ui.select`, `confirm`, `input`, `editor` | `ctx.userQuestions` |
| `ctx.ui.notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText` | A Remote stream the Web package renders |
| `registerFlag`, `getFlag`, `events` | Row configuration; an in-process event bus per Session runtime |
All Pi handlers for one Session run through one ordered queue. A notification never overtakes an earlier one, and an awaited hook such as `tool_call` runs after every notification queued before it, which keeps the order Pi's awaited dispatch gives handlers.
The Web and Desktop GUI appear to extensions as Pi's `rpc` mode with `hasUI: true`; headless, SDK, and ACP Sessions appear as `print` mode. Extensions already handle both: terminal-only calls such as `ctx.ui.custom()` return what Pi's `rpc` mode returns.
### Built-in tool calls as Pi sees them
DSH and Pi share the names `bash`, `read`, `edit`, `write`, and `grep`, but not their arguments, and Pi's `find` is DSH's `glob`. A Pi safety extension reads `event.input.path` or `event.input.command`. The bridge therefore presents each call of these six tools to handlers with Pi's name and Pi's argument names, translated from the DSH call. Other tools pass through unchanged. A test loads the registered DSH tool schemas and fails when a mapped parameter is renamed or removed, or when a tool gains a parameter the table has not classified.
The opposite direction uses the same table. `getActiveTools()` and `getAllTools()` report Pi names for the six tools and DSH names for every other tool, and `setActiveTools()` accepts the same names. `ctx.executeTool()` given a Pi name translates the arguments into the DSH call; an `edit` call with more than one entry in `edits` is refused, because one DSH `edit` call makes one replacement. An extension tool registered under a Pi built-in name replaces the corresponding DSH tool for that Agent, as it replaces Pi's own tool (`find` replaces `glob`).
### Unserved features
- An event DSH cannot raise is accepted at registration, never raised, and named in a warning when the extension loads.
- A `pi` or `ctx` member DSH cannot serve throws an error naming the member.
- Where Pi fails closed, the bridge does too. A `tool_call` handler that edits `event.input` gets the call denied with a reason, because the logged call would otherwise differ from the executed one.
### Extension state
Pi extensions rebuild their state on `session_start` from what they wrote earlier. The bridge keeps that state in the Session log so that it follows resume and fork:
- `appendEntry` and `setLabel` write ignorable records whose type starts with `plugin:`. A restricted core operation, callable only from experimental packages, appends them. They are not `SessionEventMap` members, so the persistence catalog and its type history are unchanged. A build without the bridge retains and skips them when it reads the current format, as the [ignorable events decision](../../implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) requires. A later format edge carries them on a best-effort basis: it keeps them under their names where it can and otherwise states which it drops, while the alpha historical edges refuse every unknown event ([decision](../../implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md)).
- A tool's `details` is stored as the `tool/result` metadata the tool's output declaration projects.
- `sendMessage` content is logged as an ordinary user-sourced message with the extension's content unchanged. A companion ignorable record carries `customType`, `display`, and `details`.
### Core mechanisms narrower than Pi's
Ranked by how much of Pi's API each blocks, with the GitHub issue that tracks each. Rows 1 and 3 follow from DSH rules: a request is derived from the Session log, and the log is linear. The other rows are missing extension points.
| # | Mechanism | Pi API blocked | DSH today | Issue |
|---|---|---|---|---|
| 1 | Per-request rewrite of model input | `context`, `context_with_system`, `before_provider_request`, `prepareLoadout` hidden declarations | The loop derives each request from the log and freezes it; `llm/stream` listeners read it. A rewrite needs an awaited hook and a core event that every log reader applies to that request. | #5661 |
| 2 | Tool-argument rewrite | `tool_call` editing `event.input` | `PreToolDecision` excludes input rewriting ([proposal](2026-06-30-pre-tool-input-rewrite.md)). | #5662 |
| 3 | Session tree and assistant-message replacement | `navigateTree`, `session_before_tree`, `session_tree`, `message_end` replacement | The log is linear, a fork creates a new Session, and an `assistant/message` cannot replace another. | #5663 |
| 4 | Awaited step-end and pre-commit hooks | `turn_end` entries and `continue`, `message_end` | `step/end` and `assistant/message` are committed events; a Session-event listener cannot append. | #5664 |
| 5 | Tool progress and result replacement | `onUpdate`, `tool_execution_update`, `tool_result` turning an error into a success, tool `usage` | `ToolRunContext` has no progress channel, and `tools/post-execute` cannot replace the value of a failed result. | #5665 |
| 6 | Per-tool exposure | `codemode`, `deferred`, and `hidden` exposure | Presentation is chosen per Agent, and a tool the model cannot see refuses execution. | #5666 |
| 7 | Input admission | `input` before the prompt is stored | A prompt is durable in the inbox before `agent/pre-step`; a swallowed prompt leaves a turn without a step. | #5667 |
| 8 | Logged model call by a plugin | `ctx.modelRegistry.complete`, `stream`, `streamSimple` | No Session event records a plugin's own model request, which also blocks `$.model.complete` for mods. | #5668 |
| 9 | Plugin-owned durable records | `appendEntry` | `Session.append()` accepts declared event types only. The restricted `plugin:` records close this for experimental packages. | #5669 |
| 10 | Compaction customization | `session_before_compact` | One `CompactionEngine` owns summarization, and `/compact` takes no instructions. | #5670 |
| 11 | Lifecycle reasons | `session_start` reasons `new`, `fork`, and `reload`; `session_shutdown` reasons | `agent/created` reports `startup` or `resume`; `SessionStartSource` also declares `clear` and `compact`, which nothing emits yet; `agent/disposed` carries no reason. | #5671 |
| 12 | Host-plugin display in the GUI | `ctx.ui.notify`, `setStatus`, `setWidget` | No shared mechanism; each bridge ships its own Client package. | #5672 |
| 13 | Command capabilities | Argument completion; `newSession`, `fork`, and `switchSession` moving the user to another Session | `ctx.commands` takes unstructured text and has no Client-side effects. | #5673 |
| 14 | Provider request hooks | `before_provider_headers`, `after_provider_response`, `provider_stream_event` | Headers, responses, and raw stream events stay inside each adapter. | #5674 |
| 15 | Fault isolation | An extension bug stops one Pi process | An uncaught exception in the Host disposes the application for every Session. | #5675 |
Terminal rendering (`ctx.ui.custom`, custom editors, renderers, shortcuts) is outside this list: DSH's GUI is not a terminal, and Pi's own `rpc` mode does not serve these either.
## Alternatives considered
**Depend on `@earendil-works/pi-coding-agent` and reuse its loader and runner.** It would reproduce Pi's handler ordering and error rules exactly and supply every helper extensions import. It was rejected because the exercise is to carry Pi's API on DSH mechanisms, not to embed Pi's harness: the runner is constructed over Pi's `SessionManager` and `ModelRegistry`, the package brings Pi's whole dependency closure, and its built-in tool factories would run Pi's own file and shell implementations outside DSH's filesystem and sandbox policy.
**A wrapper module per extension, like `defineMod`.** A Pi extension is already a default-exported factory with no manifest to replace, so a wrapper adds a step for every extension and prevents loading a published Pi package directory.
**Discover Pi's own locations automatically.** Loading `~/.pi/agent/extensions` and project `.pi/extensions` executes code a DSH user did not compose, which needs a project-trust decision DSH does not have. The mods bridge removed its directory loader for the same composition reason. Discovery can be a later opt-in layer.
**Run extensions in a separate process per Session.** An uncaught extension error would then reach the Host as an event. Every awaited hook would become an inter-process round trip, and Pi's edit-in-place event objects would have to be reproduced by sending changes back. Deferred until real extensions show the isolation is needed.
**Evaluate each extension module once per Session.** Concurrent Sessions would then not share module-scope state. Rejected because Pi keeps one module instance across the Sessions of a process, so an extension may rely on module state surviving a Session switch, and per-Session evaluation repeats the transpilation for every Session and subagent.
**Run extensions for root Sessions only.** It needs an explicit filter, since `agent/created` fires for every Agent, and it lets a subagent's tool calls bypass a Pi safety extension.
**Pass DSH tool names and arguments to handlers unchanged.** Pi's own `protected-paths` example reads `event.input.path`; with DSH's `file_path` it throws, and a handler written slightly differently would allow the write.
**Declare `pi/*` Session events and a `pi-extension` message source.** Correct attribution, but each is a required-on-read persistence type: the catalog and type history change, and a build without the bridge refuses the Session.
**Keep extension state in a storage domain outside the log.** State would not follow a fork, and `ctx.sessionManager.getBranch()` would not show what the extension wrote.
**Raise `context` and `before_provider_request` without logging their effect.** The model would see input that the Session log cannot reconstruct.
**Refuse to load an extension that subscribes to an unserved event.** Most extensions use several features; the mods bridge already warns and keeps the rest working.
**Build a shared GUI notification mechanism first.** It would also serve the mods bridge, but it is a core addition; the Web package of this bridge renders its own stream until that exists.
## Acceptance criteria
- Pi's MIT-licensed example extensions are committed unchanged as fixtures, and only those a test exercises. Through a real agent loop with a mock model: a permission extension denies a tool call and the model sees the reason; an extension tool is called by the model and its `details` survive a resumed Session; an extension command runs; an extension replaces the system prompt for a turn; `appendEntry` state is rebuilt on `session_start` after resume.
- A subagent's tool call reaches the handlers of its own runtime.
- The tool translation test fails when a mapped DSH tool parameter changes.
- A Loader composition test boots the bridge from a cordis.yml, and a disposal test shows every registration removed when the bridge unloads.
- A recorded-Session snapshot covers an extension-denied tool call and an extension tool call.
- `docs/subsystems/pi-extensions.md` lists every event and member with its status and every behavioral difference.
- The new packages' `src/` have 100% coverage, and `doc-sync` passes.
## Risks
- Extension code has the Host's full authority, and one uncaught error stops every Session. Mount only extensions you would run as a plugin.
- Fidelity rests on the bridge's reading of Pi 1.0.0 dispatch rules, checked against Pi's examples but not executed on Pi's code. A later Pi release can change an event or helper the bridge serves.
- An extension that imports a helper the bridge does not implement fails to load, even if it would have used that helper on one code path only.
- A future Session format edge that cannot carry plugin records loses extension state for Sessions written before it.
- Extensions that keep per-Session state at module scope mix the state of concurrent Sessions, including subagents.
- A message an extension sends is attributed to the user everywhere outside the Web package that reads the companion record.
@@ -0,0 +1,142 @@
# Agent Note: Pi 扩展桥接
Status: proposed
[English](2026-10-03-pi-extensions-bridge.md) | 中文
## 问题
Pi 1.0.0 的扩展是进程内的 TypeScript 工厂函数 `(pi: ExtensionAPI) => void`。工厂函数订阅约四十个生命周期、上下文、工具与提供方事件,注册模型工具、斜杠命令、模型提供商与 MCP server,并通过 `ctx.ui` 与用户交互([扩展指南](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/extensions.md)、[API 类型](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/src/core/extensions/types.ts))。该 API 已发布并被广泛使用:截至 2026 年 10 月,npm 上约有 11,000 个包带有 `pi-package` 关键字。
DSH 已为另外两个生态提供桥接:为 Claude Code 与 Codex 编写的 shell 钩子([hooks 组](../../../../packages/hooks/README.zh.md)),以及 Claude Code mods([兼容性页面](../../../../docs/subsystems/claude-code-mods.zh.md))。Pi 的扩展 API 比两者都宽。它允许一个进程内模块改写单次请求的消息、编辑工具参数、追加自己的持久记录,并让已经结束的运行继续。用 DSH 扩展点提供这套 API 可以回答两个问题:为另一个 harness 编写的扩展有多少能原样运行,以及 DSH 的哪些核心机制比 Pi 窄。
## 提案
新增一个实验性桥接,加载未修改的 Pi 扩展源码,并用现有的 DSH 扩展点提供 Pi 的 `ExtensionAPI`。该桥接是一次能力演示:凡现有 DSH 机制能承载的行为,就让扩展原样运行;其余部分不提供,并在兼容性页面列出每一处差异。除扩展状态所需的受限插件记录(缺口表第 9 行)外,每项核心缺口都由单独的变更弥补。
### 包与组合
- `@deepseek-ai/dsh-experimental-pi-extensions` 是 Host 包,提供 `ctx.piExtensions`。`@deepseek-ai/dsh-experimental-client-ui-pi-extensions` 是 Web 包。两者都不是可选 bundle;与 Claude Code mods 桥接一样,由一个按需启用的 overlay 组合它们。
- cordis.yml 的一行指定一个扩展来源:一个文件、一个含 `index.ts` 或 `index.js` 的目录,或一个在 `package.json` 中列出 `pi.extensions` 的 Pi 包目录。该行同时携带扩展的 flag 值。行的顺序就是 Pi 的加载顺序,也是其 handler 的分发顺序。
- 桥接不扫描 `~/.pi` 或 `.pi`,不安装包,也不判定项目信任。Pi 包中的 skill、提示词模板与主题不会被加载。
### 加载与执行
- 桥接有自己的 loader 与事件分发器。`@earendil-works/pi-coding-agent` 不是依赖。
- 源码由 `jiti` 导入,因此 TypeScript 在已构建的安装中也能加载。loader 解析 Pi 提供给扩展的那些导入:`typebox` 与 `@earendil-works/pi-ai` 解析到桥接自己的依赖;`@earendil-works/pi-coding-agent` 解析到桥接的一个模块,它实现了不需要 Pi 运行时的辅助函数(`defineTool`、工具事件类型守卫、截断辅助函数、`withFileMutationQueue`、`getAgentDir`);`@earendil-works/pi-tui` 在部署安装了该可选对等依赖(peer dependency)时解析到它。扩展导入了 loader 无法解析的模块时加载失败,错误信息会指出该模块。
- 与 mods 一样,扩展代码在 Host 进程中以进程的全部权限运行。
- 每个拥有进程内 Agent 的 Session 都有自己的运行时:每行的模块只求值一次,其工厂函数对每个 Session 运行一次,正如 Pi 为每个 Session 重新运行工厂函数。扩展保存在模块作用域的状态由 Host 进程中的所有 Session 共享;Pi 同样在一个进程的多个 Session 之间保留同一个模块实例,但同一时间只运行一个 Session。subagent 的 Session 有独立的运行时,处于 Pi 的 `print` 模式且 `hasUI: false`,因此 subagent 发起的工具调用与根 Agent 的调用经过同样的扩展 handler。
### 提供 API
| Pi API | DSH 机制 |
|---|---|
| `session_start`、`session_shutdown` | `agent/created`(在首个轮次之前等待完成)、`agent/disposed` |
| `input`(在 inbox 准入之后,见第 7 行)、`before_agent_start` | `system-prompt/assemble` 与 `agent/pre-step`:被认领的用户消息可被改写或丢弃,返回的 `systemPrompt` 替换该轮次已组装的提示词,返回的消息加入该步骤 |
| `tool_call` | `tools/pre-execute`:`block` 成为拒绝;handler 抛出异常时拒绝该调用 |
| `tool_result` | `tools/post-execute`:被替换的内容安装为结果内容 |
| `agent_before_settle` | `agent/turn-stopping`:返回的自定义条目被记录,带 `continue` 返回的消息被 steer 进该轮次 |
| `agent_start`、`agent_end`、`agent_settled`、`turn_start`、`turn_end`、`message_start`、`message_end`、`tool_execution_start`、`tool_execution_end`、`model_select`、`session_info_changed`、`session_compact` | 会话事件与 `agent/status`,作为通知按顺序送达 |
| `registerTool`、`ctx.executeTool` | Agent 作用域的 `ctx.tools` 注册;嵌套调用走完整的工具管线;内置名称遵循下文的转换 |
| `registerCommand`、`getCommands` | Agent 作用域的 `ctx.commands` |
| `sendUserMessage`、`sendMessage` | `agent.followup()`、`agent.steer()`、`agent.inject()` |
| `appendEntry`、`setLabel`、工具 `details`、`ctx.sessionManager` | ignorable Session 记录与 `tool/result` 元数据,读回时还原为 Pi 条目 |
| `getActiveTools`、`setActiveTools`、`getAllTools` | `ctx.tools.schemas()`;`setActiveTools` 对全局工具使用作用域内的 `ctx.tools.restrict()`,并增删桥接自己在 Agent 作用域的注册,因为限制不会隐藏作用域内的工具 |
| `setModel`、`getThinkingLevel`、`setThinkingLevel` | `agent/request` 覆盖 |
| `exec` | `ctx.subprocess`,不经 shell 的 argv |
| `ctx.ui.select`、`confirm`、`input`、`editor` | `ctx.userQuestions` |
| `ctx.ui.notify`、`setStatus`、`setWidget`、`setTitle`、`setEditorText` | 由 Web 包渲染的 Remote 流 |
| `registerFlag`、`getFlag`、`events` | 行配置;每个 Session 运行时一个进程内事件总线 |
同一个 Session 的所有 Pi handler 都经过一个有序队列。通知不会越过更早的通知;`tool_call` 这类需要等待的钩子,在排在它之前的所有通知之后运行。这样就保持了 Pi 的等待式分发给 handler 的顺序。
Web 与 Desktop GUI 对扩展呈现为 Pi 的 `rpc` 模式且 `hasUI: true`;headless、SDK 与 ACP Session 呈现为 `print` 模式。扩展本来就处理这两种模式:`ctx.ui.custom()` 这类仅限终端的调用返回 Pi 的 `rpc` 模式所返回的值。
### Pi 视角下的内置工具调用
DSH 与 Pi 共有 `bash`、`read`、`edit`、`write` 与 `grep` 这些名称,但参数不同,而 Pi 的 `find` 是 DSH 的 `glob`。Pi 的安全类扩展会读取 `event.input.path` 或 `event.input.command`。因此,桥接把这六个工具的每次调用以 Pi 的名称和 Pi 的参数名呈现给 handler,由 DSH 调用转换而来。其他工具原样传递。有一项测试加载已注册的 DSH 工具 schema,在被映射的参数改名或被移除时失败,在工具新增了映射表尚未归类的参数时也失败。
反方向使用同一张映射表。`getActiveTools()` 与 `getAllTools()` 对这六个工具报告 Pi 名称,对其他工具报告 DSH 名称;`setActiveTools()` 接受同样的名称。`ctx.executeTool()` 收到 Pi 名称时,把参数转换为 DSH 调用;`edits` 中多于一项的 `edit` 调用会被拒绝,因为一次 DSH `edit` 调用只做一处替换。以 Pi 内置名称注册的扩展工具会替换该 Agent 中对应的 DSH 工具,正如它在 Pi 中替换 Pi 自己的工具(`find` 替换 `glob`)。
### 不提供的功能
- DSH 无法触发的事件:注册时接受,永不触发,并在扩展加载时的警告中列出名称。
- DSH 无法提供的 `pi` 或 `ctx` 成员:抛出指明该成员的错误。
- Pi 在哪里失败关闭,桥接也在哪里失败关闭。编辑 `event.input` 的 `tool_call` handler 会使该调用被拒绝并附带原因,否则已记录的调用会与实际执行的调用不一致。
### 扩展状态
Pi 扩展在 `session_start` 时根据自己此前写入的内容重建状态。桥接把这些状态保存在 Session 日志中,使其跟随 resume 与 fork:
- `appendEntry` 与 `setLabel` 写入类型以 `plugin:` 开头的 ignorable 记录。追加它们的是一项受限的核心操作,只能由实验性包调用。它们不是 `SessionEventMap` 成员,因此持久化目录及其类型历史不变。不带该桥接的构建在读取当前格式时保留并跳过它们,这是 [ignorable 事件决策](../../implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)的要求。之后的格式迁移边以尽力而为的方式携带它们:能保留时以原名称保留,否则说明丢弃哪些;Alpha 历史格式迁移边则拒绝所有未知事件([决策](../../implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md))。
- 工具的 `details` 保存为 `tool/result` 元数据,由工具的输出声明投影得出。
- `sendMessage` 的内容记录为普通的用户来源消息,扩展的内容保持不变。一条伴随的 ignorable 记录携带 `customType`、`display` 与 `details`。
### 比 Pi 窄的核心机制
按各自阻塞的 Pi API 数量排序,并列出跟踪每一项的 GitHub issue。第 1、3 行源自 DSH 的规则:请求由 Session 日志派生,而日志是线性的。其余各行是缺失的扩展点。
| # | 机制 | 被阻塞的 Pi API | DSH 现状 | Issue |
|---|---|---|---|---|
| 1 | 按请求改写模型输入 | `context`、`context_with_system`、`before_provider_request`、`prepareLoadout` 隐藏声明 | 循环从日志派生每个请求并将其冻结;`llm/stream` 监听器只读取它。改写需要一个可等待的钩子,以及一个每个日志读取方都会应用到该请求上的核心事件。 | #5661 |
| 2 | 改写工具参数 | 编辑 `event.input` 的 `tool_call` | `PreToolDecision` 不包含输入改写([提案](2026-06-30-pre-tool-input-rewrite.zh.md))。 | #5662 |
| 3 | Session 树与 assistant 消息替换 | `navigateTree`、`session_before_tree`、`session_tree`、`message_end` 替换 | 日志是线性的,fork 会创建新的 Session,`assistant/message` 不能替换另一条。 | #5663 |
| 4 | 可等待的步骤结束钩子与提交前钩子 | `turn_end` 的条目与 `continue`、`message_end` | `step/end` 与 `assistant/message` 是已提交的事件;会话事件监听器不能追加事件。 | #5664 |
| 5 | 工具进度与结果替换 | `onUpdate`、`tool_execution_update`、把错误变为成功的 `tool_result`、工具 `usage` | `ToolRunContext` 没有进度通道,`tools/post-execute` 不能替换失败结果的值。 | #5665 |
| 6 | 按工具设置可见性 | `codemode`、`deferred` 与 `hidden` exposure | 呈现方式按 Agent 选择,模型看不到的工具拒绝执行。 | #5666 |
| 7 | 输入准入 | 在提示词被保存之前的 `input` | 提示词在 `agent/pre-step` 之前就已持久保存在 inbox 中;被吞掉的提示词会留下一个没有步骤的轮次。 | #5667 |
| 8 | 插件发起且被记录的模型调用 | `ctx.modelRegistry.complete`、`stream`、`streamSimple` | 没有会话事件记录插件自己的模型请求,这同样阻塞了 mods 的 `$.model.complete`。 | #5668 |
| 9 | 插件拥有的持久记录 | `appendEntry` | `Session.append()` 只接受已声明的事件类型。受限的 `plugin:` 记录为实验性包补上了这一点。 | #5669 |
| 10 | 定制压缩 | `session_before_compact` | 摘要由唯一的 `CompactionEngine` 负责,`/compact` 不接受指令。 | #5670 |
| 11 | 生命周期原因 | `session_start` 的 `new`、`fork` 与 `reload` 原因;`session_shutdown` 的原因 | `agent/created` 报告 `startup` 或 `resume`;`SessionStartSource` 还声明了尚无生产者的 `clear` 与 `compact`;`agent/disposed` 不带原因。 | #5671 |
| 12 | Host 插件在 GUI 中的显示 | `ctx.ui.notify`、`setStatus`、`setWidget` | 没有共享机制;每个桥接各自附带 Client 包。 | #5672 |
| 13 | 命令能力 | 参数补全;把用户带到另一个 Session 的 `newSession`、`fork` 与 `switchSession` | `ctx.commands` 接受非结构化文本,没有 Client 侧效果。 | #5673 |
| 14 | 提供方请求钩子 | `before_provider_headers`、`after_provider_response`、`provider_stream_event` | 请求头、响应与原始流事件都留在各适配器内部。 | #5674 |
| 15 | 故障隔离 | 扩展的缺陷只中止一个 Pi 进程 | Host 中未捕获的异常会为所有 Session 释放整个应用。 | #5675 |
终端渲染(`ctx.ui.custom`、自定义编辑器、渲染器、快捷键)不在此列:DSH 的 GUI 不是终端,Pi 自己的 `rpc` 模式也不提供这些。
## 考虑过的替代方案
**依赖 `@earendil-works/pi-coding-agent` 并复用其 loader 与 runner。** 这样可以精确复现 Pi 的 handler 顺序与错误规则,并提供扩展导入的全部辅助函数。否决的原因是:这项工作要用 DSH 的机制承载 Pi 的 API,而不是嵌入 Pi 的 harness。该 runner 构造于 Pi 的 `SessionManager` 与 `ModelRegistry` 之上,这个包会带入 Pi 的全部依赖闭包,而且它的内置工具工厂会在 DSH 的文件系统与沙箱策略之外运行 Pi 自己的文件与 shell 实现。
**像 `defineMod` 那样为每个扩展写一个包装模块。** Pi 扩展本身就是默认导出的工厂函数,没有需要替代的 manifest,因此包装只会给每个扩展多加一步,并且无法直接加载已发布的 Pi 包目录。
**自动发现 Pi 自己的位置。** 加载 `~/.pi/agent/extensions` 与项目的 `.pi/extensions` 会执行 DSH 用户并未组合进来的代码,这需要 DSH 尚不具备的项目信任判定。mods 桥接出于同样的组合原因移除了它的目录 loader。发现机制可以作为之后按需启用的一层。
**为每个 Session 在独立进程中运行扩展。** 这样未捕获的扩展错误会以事件的形式到达 Host。但每个需要等待的钩子都会变成一次跨进程往返,Pi 的就地编辑事件对象也必须通过回传变更来复现。待真实扩展表明确实需要这种隔离时再做。
**为每个 Session 各求值一次扩展模块。** 这样并发的 Session 不会共享模块作用域的状态。否决的原因是:Pi 在一个进程的多个 Session 之间保留同一个模块实例,扩展可能依赖模块状态在切换 Session 后仍然存在;而且按 Session 求值会为每个 Session 与 subagent 重复转译。
**只为根 Session 运行扩展。** 这需要显式过滤,因为 `agent/created` 对每个 Agent 都会触发;它还会让 subagent 的工具调用绕过 Pi 的安全类扩展。
**把 DSH 的工具名称与参数原样传给 handler。** Pi 自己的 `protected-paths` 示例读取 `event.input.path`;遇到 DSH 的 `file_path` 时它会抛出异常,而写法稍有不同的 handler 会放行这次写入。
**声明 `pi/*` 会话事件与 `pi-extension` 消息来源。** 归属是正确的,但两者都是读取时必需的持久化类型:目录与类型历史会改变,没有该桥接的构建会拒绝这个 Session。
**把扩展状态保存在日志之外的 storage domain 中。** 状态不会跟随 fork,`ctx.sessionManager.getBranch()` 也看不到扩展写入的内容。
**触发 `context` 与 `before_provider_request` 但不记录其效果。** 模型会看到 Session 日志无法重建的输入。
**拒绝加载订阅了不提供的事件的扩展。** 多数扩展同时使用多项功能;mods 桥接已经采用“发出警告并让其余部分继续工作”的做法。
**先构建共享的 GUI 通知机制。** 它也能服务 mods 桥接,但属于核心新增;在它出现之前,本桥接的 Web 包渲染自己的流。
## 验收标准
- Pi 以 MIT 许可发布的示例扩展原样提交为 fixture,且只提交有测试覆盖的那些。在使用 mock 模型的真实 agent loop 中:权限扩展拒绝一次工具调用,模型看到原因;模型调用扩展工具,其 `details` 在恢复的 Session 中仍然存在;扩展命令可以运行;扩展替换某个轮次的系统提示词;resume 之后,`appendEntry` 的状态在 `session_start` 时被重建。
- subagent 的工具调用到达它自己运行时的 handler。
- 被映射的 DSH 工具参数变化时,工具转换测试失败。
- Loader 组合测试从 cordis.yml 启动桥接,释放测试表明桥接卸载时每项注册都被移除。
- 一个录制的 Session 快照覆盖一次被扩展拒绝的工具调用和一次扩展工具调用。
- `docs/subsystems/pi-extensions.md` 列出每个事件与成员及其状态,以及每一处行为差异。
- 新包的 `src/` 覆盖率为 100%,`doc-sync` 通过。
## 风险
- 扩展代码拥有 Host 的全部权限,一个未捕获的错误会中止所有 Session。只挂载你愿意作为插件运行的扩展。
- 保真度取决于桥接对 Pi 1.0.0 分发规则的理解;它经过 Pi 示例的检验,但没有在 Pi 的代码上执行。之后的 Pi 版本可能改变桥接所提供的事件或辅助函数。
- 扩展导入了桥接未实现的辅助函数时会加载失败,即使它只在某一条代码路径上用到该函数。
- 无法携带插件记录的未来 Session 格式迁移边,会使迁移之前写入的 Session 丢失扩展状态。
- 把按 Session 区分的状态放在模块作用域的扩展,会混用并发 Session(包括 subagent)的状态。
- 扩展发送的消息,在读取伴随记录的 Web 包之外的所有地方,都归属于用户。
@@ -0,0 +1,46 @@
# Bilingual-pair consistency record for 2026-10-03-pi-plugins-compatibility.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-10-03-pi-plugins-compatibility.md
/agent-note-pi-plugins-compatibility-levels-of-support:
en: 68e63fd54584ecbc
zh: e1cedbf64975bc53
/agent-note-pi-plugins-compatibility-levels-of-support/problem:
en: f98ddabdc0ea831b
zh: e695439507286576
/agent-note-pi-plugins-compatibility-levels-of-support/proposal:
en: 37a27883d679bb6a
zh: 62c60e0cd06bf8fb
/agent-note-pi-plugins-compatibility-levels-of-support/what-a-pi-plugin-is:
en: fd37c3aa5d6c092b
zh: e39a84ef9aabaa48
/agent-note-pi-plugins-compatibility-levels-of-support/level-1-session-facets:
en: 5e08e808af0a741a
zh: eae79e9c8f1fc301
/agent-note-pi-plugins-compatibility-levels-of-support/level-2-presentation-facets-on-the-host:
en: 6a4b96426808d653
zh: c341dd2ba594ed45
/agent-note-pi-plugins-compatibility-levels-of-support/level-3-transcript-and-stored-selection:
en: defc273e0759bb9f
zh: 59a4cf0dd0b46faa
/agent-note-pi-plugins-compatibility-levels-of-support/level-4-presentation-facets-in-the-browser:
en: 5b2fcfdb38eb6121
zh: 1d583d9a6486d2dd
/agent-note-pi-plugins-compatibility-levels-of-support/level-5-parity:
en: 657aa240a06f663f
zh: 74ca5260a5a3d215
/agent-note-pi-plugins-compatibility-levels-of-support/runtime-differences-the-plugin-model-exposes:
en: 114b54fae1ff94cd
zh: 0d0dde9f0563b27b
/agent-note-pi-plugins-compatibility-levels-of-support/chord-mechanisms-to-evaluate-for-dsh:
en: e44ed4dcd7fc9eb9
zh: 825a41daa69931f9
/agent-note-pi-plugins-compatibility-levels-of-support/alternatives-considered:
en: 390883362b220cfb
zh: 1c072dd19744a92e
/agent-note-pi-plugins-compatibility-levels-of-support/acceptance-criteria:
en: 386ae81c30ec7cfc
zh: 6f8bc4257fa8898c
/agent-note-pi-plugins-compatibility-levels-of-support/risks:
en: b6410c36a55acc47
zh: e7de4e26be7a3c58
@@ -0,0 +1,140 @@
# Agent Note: Pi plugins compatibility — levels of support
Status: proposed
English | [中文](2026-10-03-pi-plugins-compatibility.zh.md)
## Problem
Pi 1.0.0 has two separate mechanisms for running customization code. Extensions are in-process TypeScript factories with a published API; the [Pi extensions bridge note](2026-10-03-pi-extensions-bridge.md) covers them. Plugins are a newer mechanism built on Pi's Chord composition runtime: a plugin package contributes facets, and each facet runs in the process its name selects. This note covers plugins.
A compatibility layer for Pi plugins is not worth building today, for two reasons the upstream sources state themselves.
- **The API is unpublished and unstable.** The plugin stack runs only with `PI_EXPERIMENTAL=1`. The `./experimental/plugin` export of `@earendil-works/pi-coding-agent` has only a `source` condition, and the npm tarball excludes its build output. Chord's plan says it "is not a stable public API contract yet" ([PLANNING.md](https://github.com/earendil-works/pi/blob/v1.0.0/packages/chord/PLANNING.md)), and pi-durable, which the Session worker runs on, says its "API changes without notice between releases" ([README](https://github.com/earendil-works/pi/blob/v1.0.0/packages/durable/README.md)). The [services README](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/src/experimental/services/README.md) lists `AgentController.navigate()`, `nextRun()`, and `resume()` as dropped.
- **A plugin can do less than an extension.** The Session worker builds its tool, hook, and prompt registry itself and does not expose it as a service, so a facet cannot register a model tool, intercept a tool call, or change the prompt. Pi ships one example plugin.
Deferring the work without a record loses two results of the investigation: the mapping from Pi's plugin services onto DSH services, which decides what each increment of support costs, and the DSH runtime properties that differ from Chord's, which matter to DSH independently of Pi.
## Proposal
Treat Pi-plugin compatibility as six levels. Each level names what a plugin author gets, the upstream change that makes it worth building, and the DSH work it needs. DSH stays at level 0 until the trigger of a higher level occurs. A level includes the levels below it, except that level 4 replaces the Host-side presentation of level 2.
| Level | Support | Upstream trigger | DSH work |
|---|---|---|---|
| 0 | None. This note is the only artifact. | — | Re-read the upstream status when Pi releases. |
| 1 | `session` facets run in one Chord host per DSH Session. `AgentController`, `Models`, and `SessionPlugins` are served over DSH services. | Pi ships the plugin API in its npm package, or removes the `PI_EXPERIMENTAL` requirement. | One experimental Host package. No core change. |
| 2 | `tui` facets run on the Host. Their slash commands become DSH commands, `select` becomes a user question, and `showStatus` a status line. Pi's example plugin runs unmodified. | Same as level 1. | Adapters, plus the Web status display of the Pi extensions bridge. |
| 3 | `Transcript` is served, and a Session's plugin selection is stored with the Session. | A plugin that consumes `Transcript` exists, or pi-durable declares `ConversationView` stable. | A projection from the Session log to `ConversationView`, and a per-Session selection record. |
| 4 | `tui` facets run in the browser and reach Session services through a generic Chord bridge. | Chord commits to browser support, and Pi defines a presentation facet that is not terminal-specific. | One generic JSON Remote endpoint, a browser facet loader, and argument text for Client commands. |
| 5 | Parity: one worker per Session, and plugins register model tools and hooks. | Pi exposes its tool and hook registry to facets. | Per-Session process isolation, and the tool and hook mappings of the Pi extensions bridge. |
## What a Pi plugin is
A plugin is a package directory. Its `package.json` may carry `chord.facets`, which maps a facet name to an entry file or disables it; without the field, Pi uses `src/session.ts` and `src/tui.ts` when they exist. An entry default-exports `{ id, setup(env) }`. `setup` is synchronous and only declares: `env.use` and `env.observe` acquire services, `env.provide` and `env.provideMany` publish them, `env.replicatedState` creates shared state, and `env.own`, `env.onActivate`, and `env.onDeactivate` attach lifecycle work. The Chord host validates the complete dependency graph, activates providers before consumers, and can replace a facet whose declared services are unchanged while consumers keep their service handles.
The server builds each entry with Chord's esbuild bundler into a content-addressed CommonJS file listed in `chord-facets.json`. The Session worker loads the `session` entry with `node:vm` after checking its SHA-256 digest, and `require` resolves only the externals the entry declared. The `tui` entry reaches each client as a JSON artifact in a service response and loads the same way. The loader is not a sandbox: plugin code runs with the process's full authority.
`pi server -e <package>` sets the server's default plugins, and `pi client -e <package>` selects plugins for one Session. Pi stores a Session's selection and refuses a different selection while that Session's worker is running.
Pi serves these to facets:
| Scope | Service | Members |
|---|---|---|
| server | `SessionDirectory`, `SessionManagement`, `PresentationPlugins` | Session list state; create, remove, attach, detach; build and reload of presentation artifacts |
| session | `AgentController` | `prompt`, `steer`, `followUp`, `cancelQueued`, `abort`, `compact`, `waitForPrompt` |
| session | `Models` | replicated catalog and selection state; `select`, `selectThinking`, `cycleThinking`, `getThinkingLevels`, `refresh` |
| session | `Transcript` | the root conversation's pi-durable `ConversationView` as replicated state |
| session | `SessionPlugins` | `reload` |
| presentation, process-local | `SlashCommands` | `register`, `replace`, `list`, `subscribe`; a command has a name, description, argument hint, optional argument completions, and `run(args, context)` |
| presentation, process-local | `PresentationUI` | `select(title, items, selected, context)`, `showStatus(message, context)` |
A service that is not process-local may contain only replicated JSON state and methods whose arguments and result are JSON and whose last parameter is a Chord `Context`.
## Level 1: session facets
The package depends on `@earendil-works/chord` at one exact version. It builds each selected package with `bundleFacetPackage` into a cache under the Harness home and loads the `session` entry with `createFacetBundleLoader`. Chord neither installs dependencies nor runs lifecycle scripts, so a package arrives with its dependencies installed.
The loader resolves two externals itself. `@earendil-works/chord` resolves to the package's own Chord instance, because Chord identifies replicated state through a registry held by the module instance. `@earendil-works/pi-coding-agent/experimental/plugin` resolves to a module that defines service tokens with Pi's IDs (`pi.agent-controller`, `pi.local.presentation-ui`, `pi.local.slash-commands`); Chord matches services by ID string, so a token defined outside Pi binds to a facet compiled against Pi's.
The package creates one Chord host per Agent in `agent/created` and disposes it with the Agent's context. Built-in facets serve Pi's Session services:
| Pi member | DSH mechanism | Difference |
|---|---|---|
| `AgentController.prompt` | `agent.followup()` | Refusal with `busy` is derived from `agent.status`. |
| `steer`, `followUp` | `agent.steer()`, `agent.followup()` | None. |
| `cancelQueued` | `agent.inbox.remove()` | Telling `already_consumed` from `not_found` reads the Session log. |
| `abort` | `agent.cancel()` | None. |
| `compact` | `ctx.compaction.compactNow()` | Non-null `customInstructions` is refused; DSH compaction takes none. |
| `waitForPrompt` | A fold over Session events from the queued message to its `turn/end` | DSH does not link a prompt to its answer; the fold does. |
| `Models` | The model catalog and model selection of `session-controller` | Reasoning-effort identifiers stand in for Pi's thinking levels. |
| `SessionPlugins.reload` | Rebuild, `FacetHost.reload()`, then dispose the retired generation | None. |
`Transcript` is not provided at this level. Chord rejects a host whose facet requires a service nobody provides, so a plugin that uses `Transcript` fails to load with an error naming it.
## Level 2: presentation facets on the Host
Pi's `tui` facets use no terminal API. They consume `SlashCommands`, `PresentationUI`, and Session services, so they can run in a second Chord host per Agent on the DSH Host, bound in-process to the Session host.
A registered slash command becomes an Agent-scoped `ctx.commands` registration whose handler passes the raw input as `args`. A command run on the Host is logged as `command/run` and `command/done`; Pi does not log presentation commands. `getArgumentCompletions` has no DSH counterpart and is not called. `PresentationUI.select` maps to `ctx.userQuestions.ask()` and needs a connected Web Client; `showStatus` maps to the status display the Pi extensions bridge adds to the Web Client.
## Level 3: transcript and stored selection
`Transcript` requires a projection from DSH Session events to pi-durable's `ConversationView`: entries in pi-durable's entry kinds plus its live, inbox, agent, and usage documents. The projection is lossy where DSH records events Pi has no entry for, and it binds DSH to a type pi-durable declares unstable.
A Session's plugin selection is stored as a `plugin:`-prefixed ignorable Session record, the mechanism the Pi extensions bridge uses for extension state, and a live Agent's selection is fixed, as in Pi.
## Level 4: presentation facets in the browser
Running `tui` facets in the page matches Pi's placement of presentation code. It needs three additions. A browser loader evaluates the CommonJS artifact, because Chord's artifact loader uses the filesystem and `node:vm`. One generic Remote endpoint carries Chord's service calls, snapshots, and updates as JSON between the page and the Host, because DSH generates Remote methods at build time and the Client does not discover Host services at runtime. Client commands accept argument text, which `ui-commands` actions do not receive today.
## Level 5: parity
Pi isolates each Session in a worker process and plans to give plugins tools and hooks through its registry. Matching that needs a DSH Session to run in its own process or worker thread with Chord's service protocol across the process boundary, and, once Pi exposes the registry, the tool, hook, and prompt mappings that the Pi extensions bridge already defines.
## Runtime differences the plugin model exposes
| Property | Chord and Pi | DSH | Levels affected |
|---|---|---|---|
| Provider replacement | A consumer holds a handle that resolves the current provider on each access; a reload swaps the implementation behind it. | A fiber whose injected service changes is unloaded and re-run. | 1: the Chord host keeps Chord's behavior for services between facets; a DSH service mapped into Chord still restarts its adapter. |
| Fault isolation | One worker process per Session. | All Sessions share the Host process, and an uncaught exception or unhandled rejection disposes the application ([app-boot](../../../../packages/boot/app-boot/README.md)). | 1–4 run plugin code in the Host; 5 removes the difference. |
| Remote declaration | A host publishes a service's remote members from the object it was given at runtime. | Remote methods are generated at build time, and the Client does not discover Host services at runtime ([remotes](../../../../packages/api/remotes/README.md)). | 4 |
| Shared state | Replicated JSON state with operation batches, hydration, and gap recovery, read through one API locally and remotely. | Each domain defines its own snapshot and delta types, or publishes complete projection values. | 3, 4 |
| Runtime loading | One bundle per facet, loaded outside the module cache after an integrity check. | Packages are built ahead of time; a built installation does not transpile TypeScript. | 1 adds Chord's bundler as a dependency of the plugin package. |
## Chord mechanisms to evaluate for DSH
Four Chord mechanisms address limits DSH has independently of Pi. Each needs its own proposal; this note only records them.
- **Stable service handles.** A consumer keeps its handle while a provider is replaced, so a plugin reload does not restart its dependents and lose their state.
- **Declaration before activation.** Setup declares every dependency synchronously, and the host rejects a missing provider, a duplicate provider, or a cycle before any facet activates. Cordis resolves dependencies as services appear, so a fiber with a missing provider stays pending until an audit after startup reports it.
- **Replicated JSON state.** One primitive gives local and remote consumers complete immutable values, with operation batches on the wire and a full reset when a subscriber falls behind.
- **Verified plugin artifacts.** A content-addressed bundle with a manifest digest lets a host load a plugin generation outside the module cache and release it when retired.
## Alternatives considered
**Build levels 1 and 2 now as a prototype.** The API is unpublished, has one example plugin, and its services README already lists three `AgentController` methods as dropped. A prototype would need maintenance against each Pi release without a user. The service mapping above keeps the design result without that cost.
**Skip the record and decide when Pi stabilizes.** The service mapping and the runtime comparison took a full reading of Chord and Pi's experimental server, worker, and client. The runtime differences also apply to DSH work that has nothing to do with Pi.
**Implement Pi's plugin services on Cordis without Chord.** A facet calls Chord's API directly: `env.use`, `env.provide`, and `replicatedState`. Serving that API without Chord means reimplementing graph validation, stable handles, keyed services, and replicated state. Chord is MIT-licensed, has one runtime dependency, and is the runtime plugins are compiled against.
**Add plugins to the Pi extensions bridge package.** The two mechanisms share no API and have different stability. Separate packages let each be enabled, versioned, and removed without the other.
**Run presentation facets only in the browser.** Level 2 reuses Pi's Node artifacts unchanged and needs no browser loader. Browser support is an open decision in Chord's plan, and Pi has no presentation facet that is not terminal-specific.
## Acceptance criteria
- **Level 0:** this note is merged, and the Pi extensions compatibility page links to it for the plugin mechanism.
- **Level 1:** the `session` facet of Pi's example plugin loads from its unmodified package directory. A test calls its greeting service and every served `AgentController` member against a real agent loop with a mock model. `SessionPlugins.reload` replaces the facet generation without recreating the Chord host, and disposing the Agent disposes the host.
- **Level 2:** `/hello <name>` from Pi's example plugin runs through `ctx.commands`, shows its status text in the Web Client, and submits its prompt.
- **Level 3:** a facet that subscribes to `Transcript` receives the root conversation after hydration and after each committed step, and a resumed Session loads the plugins it was created with.
- **Level 4:** the `tui` facet of Pi's example plugin runs in the browser, and its service calls reach the Host through the generic endpoint.
- **Every level:** a compatibility page lists each difference from Pi, and the package's README states that plugin code runs in the Host with its full authority.
## Risks
- The upstream API can change or be withdrawn before a level is built. The service mapping describes Pi 1.0.0 and needs a re-read against the release that triggers the work.
- Levels 1 to 4 run plugin code in the Host process, where one uncaught error stops every Session.
- Level 3 depends on a pi-durable type that upstream declares unstable.
- Level 4 adds a Remote endpoint whose payloads are validated by Chord's wire parsers instead of generated per-method codecs.
- The plugin package brings esbuild as a runtime dependency through Chord's bundler.
@@ -0,0 +1,140 @@
# Agent Note: Pi 插件兼容——分级支持
Status: proposed
[English](2026-10-03-pi-plugins-compatibility.md) | 中文
## 问题
Pi 1.0.0 有两套相互独立、用于运行定制代码的机制。扩展是进程内的 TypeScript 工厂函数,API 已发布,由 [Pi 扩展桥接 Agent Note](2026-10-03-pi-extensions-bridge.zh.md) 负责。插件是较新的机制,构建在 Pi 的 Chord 组合运行时之上:一个插件包贡献若干 facet,每个 facet 运行在其名称选定的进程里。本 Agent Note 只讨论插件。
现在还不值得为 Pi 插件构建兼容层,上游资料自己给出了两条理由。
- **API 未发布且不稳定。** 插件栈只在 `PI_EXPERIMENTAL=1` 下运行。`@earendil-works/pi-coding-agent` 的 `./experimental/plugin` 导出只有 `source` 条件,npm tarball 不包含它的构建产物。Chord 的规划文档称它“is not a stable public API contract yet”([PLANNING.md](https://github.com/earendil-works/pi/blob/v1.0.0/packages/chord/PLANNING.md));Session worker 所基于的 pi-durable 称其“API changes without notice between releases”([README](https://github.com/earendil-works/pi/blob/v1.0.0/packages/durable/README.md))。[services README](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/src/experimental/services/README.md) 把 `AgentController.navigate()`、`nextRun()` 与 `resume()` 列为已移除。
- **插件能做的事比扩展少。** Session worker 自行构建工具、钩子与提示词注册表,不把它暴露为服务,因此 facet 无法注册模型工具、拦截工具调用或修改提示词。Pi 只附带一个示例插件。
如果不留记录就搁置这项工作,会丢失调研得到的两项结果:Pi 插件服务到 DSH 服务的映射,它决定每一级支持的成本;以及 DSH 运行时与 Chord 的差异,这些差异与 Pi 无关,对 DSH 自身同样重要。
## 提案
把 Pi 插件兼容划分为六个级别。每一级说明插件作者能得到什么、哪项上游变化使它值得构建,以及需要哪些 DSH 工作。在更高级别的触发条件出现之前,DSH 停留在 0 级。每一级包含其下各级,唯一例外是 4 级取代 2 级在 Host 侧的 presentation 实现。
| 级别 | 支持内容 | 上游触发条件 | DSH 工作 |
|---|---|---|---|
| 0 | 无。本 Agent Note 是唯一产物。 | — | Pi 发版时重新核对上游状态。 |
| 1 | `session` facet 运行在每个 DSH Session 各自的 Chord host 中。`AgentController`、`Models` 与 `SessionPlugins` 由 DSH 服务提供。 | Pi 在 npm 包中发布插件 API,或取消 `PI_EXPERIMENTAL` 要求。 | 一个实验性 Host 包。不改核心。 |
| 2 | `tui` facet 运行在 Host 上。它们的斜杠命令成为 DSH 命令,`select` 成为用户提问,`showStatus` 成为状态行。Pi 的示例插件原样运行。 | 同 1 级。 | 适配器,以及 Pi 扩展桥接的 Web 状态显示。 |
| 3 | 提供 `Transcript`,并把 Session 的插件选择随 Session 保存。 | 出现消费 `Transcript` 的插件,或 pi-durable 宣布 `ConversationView` 稳定。 | 从 Session 日志到 `ConversationView` 的投影,以及按 Session 保存的选择记录。 |
| 4 | `tui` facet 运行在浏览器中,经通用 Chord 桥访问 Session 服务。 | Chord 承诺支持浏览器,且 Pi 定义了不依赖终端的 presentation facet。 | 一个通用 JSON Remote 端点、一个浏览器 facet loader,以及 Client 命令的参数文本。 |
| 5 | 对等:每个 Session 一个 worker,插件可注册模型工具与钩子。 | Pi 把工具与钩子注册表暴露给 facet。 | 按 Session 的进程隔离,以及 Pi 扩展桥接已有的工具与钩子映射。 |
## Pi 插件是什么
插件是一个包目录。它的 `package.json` 可以带 `chord.facets`,把 facet 名称映射到入口文件或将其禁用;没有该字段时,Pi 在 `src/session.ts` 与 `src/tui.ts` 存在的情况下使用它们。入口默认导出 `{ id, setup(env) }`。`setup` 是同步的,只做声明:`env.use` 与 `env.observe` 获取服务,`env.provide` 与 `env.provideMany` 发布服务,`env.replicatedState` 创建共享状态,`env.own`、`env.onActivate` 与 `env.onDeactivate` 挂接生命周期工作。Chord host 校验完整的依赖图,先激活提供方再激活消费方,并且能在消费方保留服务句柄的同时替换所声明服务不变的 facet。
服务器用 Chord 的 esbuild 打包器把每个入口构建为内容寻址的 CommonJS 文件,并登记在 `chord-facets.json` 中。Session worker 校验 SHA-256 摘要后用 `node:vm` 加载 `session` 入口,`require` 只解析该入口声明过的外部依赖。`tui` 入口作为 JSON 制品随服务响应到达各客户端,并以同样方式加载。该 loader 不是沙箱:插件代码以进程的全部权限运行。
`pi server -e <package>` 设置服务器的默认插件,`pi client -e <package>` 为单个 Session 选择插件。Pi 保存 Session 的选择,并在该 Session 的 worker 运行期间拒绝不同的选择。
Pi 向 facet 提供以下服务:
| 作用域 | 服务 | 成员 |
|---|---|---|
| server | `SessionDirectory`、`SessionManagement`、`PresentationPlugins` | Session 列表状态;创建、删除、attach、detach;presentation 制品的构建与重载 |
| session | `AgentController` | `prompt`、`steer`、`followUp`、`cancelQueued`、`abort`、`compact`、`waitForPrompt` |
| session | `Models` | 可复制的目录与选择状态;`select`、`selectThinking`、`cycleThinking`、`getThinkingLevels`、`refresh` |
| session | `Transcript` | 根对话的 pi-durable `ConversationView`,以可复制状态提供 |
| session | `SessionPlugins` | `reload` |
| presentation,进程内 | `SlashCommands` | `register`、`replace`、`list`、`subscribe`;命令包含名称、描述、参数提示、可选的参数补全,以及 `run(args, context)` |
| presentation,进程内 | `PresentationUI` | `select(title, items, selected, context)`、`showStatus(message, context)` |
非进程内的服务只能包含可复制的 JSON 状态,以及参数与结果均为 JSON、最后一个参数为 Chord `Context` 的方法。
## 1 级:session facet
该包依赖某一精确版本的 `@earendil-works/chord`。它用 `bundleFacetPackage` 把每个选中的包构建到 Harness 主目录下的缓存,并用 `createFacetBundleLoader` 加载 `session` 入口。Chord 既不安装依赖也不运行生命周期脚本,因此包到达时必须已装好依赖。
loader 自行解析两个外部依赖。`@earendil-works/chord` 解析为该包自己的 Chord 实例,因为 Chord 通过模块实例持有的注册表识别可复制状态。`@earendil-works/pi-coding-agent/experimental/plugin` 解析为一个模块,它用 Pi 的 ID(`pi.agent-controller`、`pi.local.presentation-ui`、`pi.local.slash-commands`)定义服务 token;Chord 按 ID 字符串匹配服务,因此在 Pi 之外定义的 token 可以绑定到按 Pi 的 token 编译的 facet。
该包在 `agent/created` 中为每个 Agent 创建一个 Chord host,并随 Agent 的上下文一同释放。内置 facet 提供 Pi 的 Session 服务:
| Pi 成员 | DSH 机制 | 差异 |
|---|---|---|
| `AgentController.prompt` | `agent.followup()` | 以 `busy` 拒绝的行为由 `agent.status` 推导。 |
| `steer`、`followUp` | `agent.steer()`、`agent.followup()` | 无。 |
| `cancelQueued` | `agent.inbox.remove()` | 区分 `already_consumed` 与 `not_found` 需要读取 Session 日志。 |
| `abort` | `agent.cancel()` | 无。 |
| `compact` | `ctx.compaction.compactNow()` | 拒绝非空的 `customInstructions`;DSH 压缩不接受指令。 |
| `waitForPrompt` | 对 Session 事件做 fold,从入队消息到它的 `turn/end` | DSH 不关联提示词与其回答;由该 fold 完成关联。 |
| `Models` | `session-controller` 的模型目录与模型选择 | 以推理强度标识符代替 Pi 的 thinking level。 |
| `SessionPlugins.reload` | 重新构建、调用 `FacetHost.reload()`,再释放退役的 generation | 无。 |
本级不提供 `Transcript`。Chord 会拒绝含有“所需服务无人提供”的 facet 的 host,因此使用 `Transcript` 的插件加载失败,错误信息会指出该服务。
## 2 级:Host 上的 presentation facet
Pi 的 `tui` facet 不使用任何终端 API。它们消费 `SlashCommands`、`PresentationUI` 与 Session 服务,因此可以运行在 DSH Host 上每个 Agent 的第二个 Chord host 中,并在进程内绑定到 Session host。
注册的斜杠命令成为 Agent 作用域的 `ctx.commands` 注册,其 handler 把原始输入作为 `args` 传入。在 Host 上运行的命令会记录为 `command/run` 与 `command/done`;Pi 不记录 presentation 命令。`getArgumentCompletions` 在 DSH 中没有对应物,不会被调用。`PresentationUI.select` 映射到 `ctx.userQuestions.ask()`,需要已连接的 Web Client;`showStatus` 映射到 Pi 扩展桥接为 Web Client 增加的状态显示。
## 3 级:transcript 与保存的选择
`Transcript` 需要从 DSH Session 事件到 pi-durable `ConversationView` 的投影:按 pi-durable 条目类型组织的条目,加上它的 live、inbox、agent 与 usage 文档。DSH 记录了 Pi 没有对应条目的事件,在这些地方投影是有损的;它还把 DSH 绑定到一个被 pi-durable 声明为不稳定的类型上。
Session 的插件选择保存为带 `plugin:` 前缀的 ignorable Session 记录,即 Pi 扩展桥接保存扩展状态所用的机制;与 Pi 一样,存活 Agent 的选择固定不变。
## 4 级:浏览器中的 presentation facet
在页面中运行 `tui` facet 与 Pi 对 presentation 代码的放置一致。它需要三项新增。浏览器 loader 负责求值 CommonJS 制品,因为 Chord 的制品 loader 使用文件系统与 `node:vm`。一个通用 Remote 端点以 JSON 在页面与 Host 之间承载 Chord 的服务调用、快照与更新,因为 DSH 在构建时生成 Remote 方法,Client 不在运行时发现 Host 服务。Client 命令需要接受参数文本,目前 `ui-commands` 的 action 收不到它。
## 5 级:对等
Pi 把每个 Session 隔离在一个 worker 进程中,并计划通过其注册表向插件提供工具与钩子。要达到这一点,DSH Session 需要运行在自己的进程或 worker 线程中,并让 Chord 的服务协议跨越进程边界;在 Pi 暴露注册表之后,还需要 Pi 扩展桥接已经定义的工具、钩子与提示词映射。
## 插件模型暴露出的运行时差异
| 属性 | Chord 与 Pi | DSH | 受影响的级别 |
|---|---|---|---|
| 提供方替换 | 消费方持有句柄,每次访问时解析当前提供方;重载在句柄背后替换实现。 | 所注入服务发生变化的 fiber 会被卸载并重新运行。 | 1:Chord host 对 facet 之间的服务保持 Chord 的行为;映射进 Chord 的 DSH 服务仍会重启其适配器。 |
| 故障隔离 | 每个 Session 一个 worker 进程。 | 所有 Session 共享 Host 进程,未捕获的异常或未处理的 rejection 会释放整个应用([app-boot](../../../../packages/boot/app-boot/README.zh.md))。 | 1–4 在 Host 中运行插件代码;5 消除这一差异。 |
| Remote 声明 | host 根据运行时拿到的对象发布服务的远程成员。 | Remote 方法在构建时生成,Client 不在运行时发现 Host 服务([remotes](../../../../packages/api/remotes/README.zh.md))。 | 4 |
| 共享状态 | 可复制的 JSON 状态,带操作批次、hydration 与缺口恢复,本地与远程通过同一 API 读取。 | 每个领域定义自己的快照与增量类型,或发布完整的投影值。 | 3、4 |
| 运行时加载 | 每个 facet 一个 bundle,经完整性校验后在模块缓存之外加载。 | 包提前构建;已构建的安装不转译 TypeScript。 | 1 把 Chord 的打包器加为插件包的依赖。 |
## 值得为 DSH 评估的 Chord 机制
有四项 Chord 机制针对的是 DSH 自身存在、与 Pi 无关的限制。每一项都需要单独的提案;本 Agent Note 只做记录。
- **稳定的服务句柄。** 提供方被替换时消费方保留句柄,因此插件重载不会重启其依赖方并丢失它们的状态。
- **先声明后激活。** setup 同步声明全部依赖,host 在任何 facet 激活之前拒绝缺失的提供方、重复的提供方或循环依赖。Cordis 随服务出现逐步解析依赖,因此缺少提供方的 fiber 会一直挂起,直到启动后的审计报告它。
- **可复制的 JSON 状态。** 一个原语为本地与远程消费方提供完整的不可变值,线上传输操作批次,订阅方落后时做一次完整重置。
- **经过校验的插件制品。** 带 manifest 摘要的内容寻址 bundle 让 host 在模块缓存之外加载某一代插件,并在其退役后释放。
## 考虑过的替代方案
**现在就把 1、2 级做成原型。** 该 API 未发布,只有一个示例插件,其 services README 已把三个 `AgentController` 方法列为已移除。原型需要跟随 Pi 的每次发版维护,却没有用户。上文的服务映射保留了设计结论,而不必付出这项成本。
**不留记录,等 Pi 稳定后再决定。** 服务映射与运行时对比来自对 Chord 以及 Pi 实验性 server、worker 与 client 的完整阅读。这些运行时差异同样适用于与 Pi 无关的 DSH 工作。
**不用 Chord,直接在 Cordis 上实现 Pi 的插件服务。** facet 直接调用 Chord 的 API:`env.use`、`env.provide` 与 `replicatedState`。不用 Chord 而提供这套 API,意味着重新实现依赖图校验、稳定句柄、keyed 服务与可复制状态。Chord 采用 MIT 许可,只有一个运行时依赖,而且插件正是针对它编译的。
**把插件并入 Pi 扩展桥接包。** 两套机制没有共同的 API,稳定性也不同。分成独立的包,才能各自启用、定版与移除。
**只在浏览器中运行 presentation facet。** 2 级原样复用 Pi 的 Node 制品,不需要浏览器 loader。浏览器支持在 Chord 的规划中仍是未决事项,Pi 也没有不依赖终端的 presentation facet。
## 验收标准
- **0 级:** 本 Agent Note 已合并,Pi 扩展兼容性页面在插件机制处链接到它。
- **1 级:** Pi 示例插件的 `session` facet 从未修改的包目录加载。测试在使用 mock 模型的真实 agent loop 上调用它的问候服务以及每个已提供的 `AgentController` 成员。`SessionPlugins.reload` 替换 facet generation 而不重建 Chord host,释放 Agent 时 host 随之释放。
- **2 级:** Pi 示例插件的 `/hello <name>` 经 `ctx.commands` 运行,在 Web Client 中显示其状态文本,并提交其提示词。
- **3 级:** 订阅 `Transcript` 的 facet 在 hydration 之后以及每个已提交的步骤之后收到根对话,恢复的 Session 加载其创建时所用的插件。
- **4 级:** Pi 示例插件的 `tui` facet 在浏览器中运行,其服务调用经通用端点到达 Host。
- **每一级:** 兼容性页面列出与 Pi 的每一处差异,包的 README 说明插件代码以 Host 的全部权限运行。
## 风险
- 在任何一级构建之前,上游 API 都可能变化或被撤回。服务映射描述的是 Pi 1.0.0,需要对照触发这项工作的发行版重新核对。
- 1 至 4 级在 Host 进程中运行插件代码,一个未捕获的错误会中止所有 Session。
- 3 级依赖一个被上游声明为不稳定的 pi-durable 类型。
- 4 级增加的 Remote 端点由 Chord 的 wire 解析器校验载荷,而不是由按方法生成的编解码器校验。
- 插件包经由 Chord 的打包器引入 esbuild 作为运行时依赖。
+1 -1
View File
@@ -141,7 +141,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
- `@earendil-works/pi-ai@0.87.1` — [`patches/@earendil-works__pi-ai@0.87.1.patch`](patches/@earendil-works__pi-ai@0.87.1.patch)
- `@earendil-works/pi-ai@1.0.0` — [`patches/@earendil-works__pi-ai@1.0.0.patch`](patches/@earendil-works__pi-ai@1.0.0.patch)
- `@electron/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
@@ -5,10 +5,11 @@
* statically at module scope, so the row cannot mount without it.
*
* Every symbol `llm-pi-ai` imports by name is present: a missing CommonJS symbol
* would surface as `undefined` at call time instead of a link error. The three catalog readers
* return empty collections rather than throwing — the row reads them while it
* activates, and "this deployment ships no pi-ai provider" is the truth here.
* Everything on a request path is loud.
* would surface as `undefined` at call time instead of a link error. The row
* reads the catalog while it activates, so the provider reader returns
* providers without models and the two model readers return empty collections
* instead of throwing: this deployment serves no pi-ai model. Everything on a
* request path is loud.
*/
import { notImplementedFail } from '../notImplementedFail.ts'
@@ -26,7 +27,14 @@ export const getSupportedThinkingLevels = notImplementedFail(MODULE, 'getSupport
/** Context-overflow predicate (unavailable). */
export const isContextOverflow = notImplementedFail(MODULE, 'isContextOverflow')
/** Builtin provider ids of pi-ai 0.84.2, in catalog order. */
/** Collection of every builtin provider (unavailable). */
export const builtinModels = notImplementedFail(MODULE, 'builtinModels')
/**
* Builtin provider ids as pi-ai 0.84.2 listed them, in catalog order. The list
* is a fixed copy that pi-ai upgrades do not regenerate, so it omits providers
* added since.
*/
const BUILTIN_PROVIDER_IDS: readonly string[] = [
'amazon-bedrock', 'ant-ling', 'anthropic', 'azure-openai-responses', 'baseten', 'cerebras',
'cloudflare-ai-gateway', 'cloudflare-workers-ai', 'deepseek', 'fireworks', 'github-copilot',
@@ -39,10 +47,11 @@ const BUILTIN_PROVIDER_IDS: readonly string[] = [
]
/**
* Installed catalog providers, read while `llm-pi-ai` activates. Each carries the
* api-key auth marker the adapter filters on, and no models: the provider
* directory therefore matches the served deployment while every request path
* lands on a loud symbol above.
* Installed catalog providers, read while `llm-pi-ai` activates. `llm-pi-ai`
* registers the whole catalog as configurable the moment it mounts and rejects
* an empty registration, so these carry pi-ai's ids instead of being an empty
* list. Each has the api-key auth marker the adapter filters on, and no
* models, so every request path lands on a loud symbol above.
* @returns one entry per builtin provider.
*/
export function builtinProviders(): unknown[] {
@@ -54,16 +63,6 @@ export function builtinProviders(): unknown[] {
}))
}
/**
* Provider route ids of the installed catalog. `llm-pi-ai` registers the whole
* catalog as configurable the moment it mounts and rejects an empty
* registration, so these are pi-ai's real ids rather than an empty list.
* @returns the builtin provider ids.
*/
export function getBuiltinProviders(): string[] {
return [...BUILTIN_PROVIDER_IDS]
}
/**
* Models of one installed catalog provider.
* @returns no models.
@@ -72,6 +71,14 @@ export function getBuiltinModels(): unknown[] {
return []
}
/**
* Models of every type of one installed catalog provider.
* @returns no models.
*/
export function getAllBuiltinModels(): unknown[] {
return []
}
/** Anthropic messages API binding (unavailable). */
export const anthropicMessagesApi = notImplementedFail(MODULE, 'anthropicMessagesApi')
@@ -86,7 +93,6 @@ export const __esModule = true
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
createProvider, createModels, getSupportedThinkingLevels, isContextOverflow, builtinProviders,
getBuiltinModels, getBuiltinProviders, anthropicMessagesApi, openAICompletionsApi,
openAIResponsesApi,
createProvider, createModels, getSupportedThinkingLevels, isContextOverflow, builtinModels, builtinProviders,
getBuiltinModels, getAllBuiltinModels, anthropicMessagesApi, openAICompletionsApi, openAIResponsesApi,
}
@@ -50,7 +50,7 @@ const CALLED: [string, Record<string, unknown>, readonly string[]][] = [
['got', got.default, ['post']],
['@deepseek-ai/pi-ai', piAi, [
'createProvider', 'createModels', 'openAICompletionsApi', 'openAIResponsesApi', 'anthropicMessagesApi',
'isContextOverflow', 'getSupportedThinkingLevels',
'isContextOverflow', 'getSupportedThinkingLevels', 'builtinModels',
]],
]
+4 -4
View File
@@ -51,8 +51,8 @@
en: 20afee7bbfb332ca
zh: b080cf4e97094366
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/registration-and-directory:
en: 4da379c9be65c18a
zh: fd305d4bd3dfa452
en: 3e12da237dea76da
zh: f4d917343e822d2d
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/replay-and-vocabulary:
en: 36e0c59a0f9f5980
zh: 90a62c4a9cd23429
@@ -87,8 +87,8 @@
en: b6bd570c0159ba08
zh: 7d2d91eea6a8d830
/deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work:
en: bb240ee5dd3f81c7
zh: 884506a36f4aac62
en: 3ac77ae061cf0683
zh: c25abff6da8c1a22
/deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work/dev-note:
en: 3678dd0f6642c2a5
zh: 75cbdd064cc64282
+3 -2
View File
@@ -152,7 +152,7 @@ The adapter is built on immutable snapshots and per-operation resolution. Each o
### Registration and directory
The plugin declares every installed catalog provider it can authenticate in the configurable-provider directory, joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists. Each entry carries `declared` — whether pi-ai ships nothing under that key — because only the adapter can distinguish a hand-declared route from a narrowed catalog route. Route registration is atomic: a candidate set that collides with another adapter leaves the previous routes serving. A bare mount with zero routes is the dormant posture: nothing registers until the Config supplies profiles, and routes drop when it empties.
The plugin declares every installed catalog provider it can authenticate in the configurable-provider directory, joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists. A catalog provider whose catalog lists models but no chat model, such as one of classifier models alone, is left out and gets no sign-in flow, because this adapter dispatches chat requests only. Each entry carries `declared` — whether pi-ai ships nothing under that key — because only the adapter can distinguish a hand-declared route from a narrowed catalog route. Route registration is atomic: a candidate set that collides with another adapter leaves the previous routes serving. A bare mount with zero routes is the dormant posture: nothing registers until the Config supplies profiles, and routes drop when it empties.
### Replay and vocabulary
@@ -216,6 +216,7 @@ These limits define where the adapter stops and future work begins. They are cur
- **`maxRequestImageBytes` counts base64 image payload only** — text, tools, descriptors, and JSON structure ride outside the bound, so it must sit below the gateway's request-body cap with headroom.
- **A sign-in lives only in the process that started it** — an authorization attempt is not durable, so reloading the page mid-login abandons it and the human starts over. Signing out is `deleteRecord` on the stored record, which forgets it locally without telling the issuer.
- **OpenAI offers no ChatGPT sign-in** — pi-ai's Sign in with ChatGPT identifies the installation to OpenAI with a stable UUID the app supplies, and the harness keeps no installation ID meant for a third-party account sign-in; the `openai` route signs in with an API key.
- **Provider-native discovery answers through this plugin's ambient context** — a route naming no credential defers to the catalog provider's own resolution, which asks for environment values (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, and each provider's own set) and for local credential files. Both questions are answered here: the credential seam is consulted before the process environment, and file existence is checked against the host process's filesystem with `~` expanded. What it cannot do is *read* a credential file's contents — a provider that parses `~/.aws/credentials` itself does so directly, outside the seam.
- **Reset restores inherited configuration** — resetting a route supplied by a lower profile layer restores that route.
- **Complete Config replacement can remove inherited dictionary entries** — a field reset instead restores its inherited value.
@@ -229,7 +230,7 @@ These limits define where the adapter stops and future work begins. They are cur
- **Only a leading in-history `system` message becomes pi-ai's `systemPrompt`** — this adapter uses pi-ai's single `systemPrompt` input, so a later `system` message, or a leading one when `GenerateOptions.system` is also set, folds into a `user` message at its position; provider-specific placement of the prompt follows pi-ai rather than a harness-owned wire override. Images in system or assistant history, including the leading system message, fail with `UNSUPPORTED_CONTENT` on both conversion paths.
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers.
- **Retry policy is provider-owned, not an SDK retry** — pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt.
- **Streamed tool-call arguments are parsed once, when the call ends** — the installed pi-ai carries [`patches/@earendil-works__pi-ai@0.87.1.patch`](../../../patches/@earendil-works__pi-ai@0.87.1.patch), which removes the per-delta re-parse of the whole accumulated argument JSON in every stream adapter (upstream [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265)); unpatched, a multi-megabyte argument stream costs O(n²) CPU on the event loop and stalls every session in the process. Until `toolcall_end`, a pi-ai partial's tool-call `arguments` stays `{}`; this adapter reads only the delta strings and the finalized arguments. Re-apply or retire the patch on every pi-ai upgrade.
- **Streamed tool-call arguments are parsed once, when the call ends** — the installed pi-ai carries [`patches/@earendil-works__pi-ai@1.0.0.patch`](../../../patches/@earendil-works__pi-ai@1.0.0.patch), which removes the per-delta re-parse of the whole accumulated argument JSON in every stream adapter (upstream [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265)); unpatched, a multi-megabyte argument stream costs O(n²) CPU on the event loop and stalls every session in the process. Until `toolcall_end`, a pi-ai partial's tool-call `arguments` stays `{}`; this adapter reads only the delta strings and the finalized arguments. Re-apply or retire the patch on every pi-ai upgrade.
<a id="dev-note"></a>
### Dev Note
+3 -2
View File
@@ -152,7 +152,7 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的
### 注册与目录
插件会在可配置提供方目录中声明它能认证的每个已安装目录提供方,并加入当前 profile 声明的每条路由,因此配置界面可以在任何路由存在之前提供完整目录。每个条目都携带 `declared`——pi-ai 是否在该键下不提供任何内容——因为只有适配器能区分手工声明路由与收窄目录路由。路由注册具有原子性:与其他适配器冲突的候选集合会让此前路由继续服务。零路由的裸挂载即休眠姿态:settings 分节提供 profile 前不注册任何内容,分节清空时路由随之消失。
插件会在可配置提供方目录中声明它能认证的每个已安装目录提供方,并加入当前 profile 声明的每条路由,因此配置界面可以在任何路由存在之前提供完整目录。目录列出了模型却没有聊天模型的提供方(例如只有分类器模型)不进入目录,也不注册登录流程,因为本适配器只分派聊天请求。每个条目都携带 `declared`——pi-ai 是否在该键下不提供任何内容——因为只有适配器能区分手工声明路由与收窄目录路由。路由注册具有原子性:与其他适配器冲突的候选集合会让此前路由继续服务。零路由的裸挂载即休眠姿态:settings 分节提供 profile 前不注册任何内容,分节清空时路由随之消失。
### 回放与词汇
@@ -216,6 +216,7 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish
- **`maxRequestImageBytes` 只计算 base64 图片载荷**,文本、工具、描述符与 JSON 结构在该上限之外,因此它必须留有余量地低于网关请求体上限。
- **登录只存在于发起它的进程中**——授权尝试不持久,因此登录中途刷新页面会放弃它,用户需要重新开始。退出登录是对已存储记录执行 `deleteRecord`,只在本地忘记它,不会告知签发方。
- **OpenAI 不提供 ChatGPT 登录**——pi-ai 的 Sign in with ChatGPT 用应用提供的稳定 UUID 向 OpenAI 标识安装实例,而 harness 没有用于第三方账号登录的安装 ID;`openai` 路由改用 API key 登录。
- **提供方原生发现经本插件的 ambient context 回答**——不点名凭据的路由交由目录提供方自身解析,它会询问环境值(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE` 及各提供方自有集合)与本地凭据文件。两个问题都在这里得到回答:凭据 seam 先于进程环境被查询,文件存在性则针对宿主进程的文件系统以 `~` 展开后检查。它做不到的是*读取*凭据文件内容——自行解析 `~/.aws/credentials` 的提供方会直接读取,不经该 seam。
- **重置恢复继承配置**——重置下层 profile 提供的路由会恢复该路由。
- **完整替换 Config 可以移除继承的字典条目**——字段重置则恢复其继承值。
@@ -229,7 +230,7 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish
- **只有历史中首条 `system` 消息会成为 pi-ai 的 `systemPrompt`**——本适配器使用 pi-ai 的单一 `systemPrompt` 输入,因此后续的 `system` 消息,或在同时设置了 `GenerateOptions.system` 时的首条消息,会在原位置折叠为 `user` 消息;系统提示词的提供方专属放置遵循 pi-ai,而非 harness 自有的协议覆盖。system 或 assistant 历史中的图片(包括首条系统消息中的图片)在两条转换路径上都会以 `UNSUPPORTED_CONTENT` 失败。
- **提供方 HTTP 状态不可用**——pi-ai 错误事件不跨提供方暴露稳定 HTTP 状态。
- **重试策略由提供方自有,而非 SDK 重试**——pi-ai SDK 重试保持禁用,因此持久 agent(智能体)步骤与 `llm/retry` 事件拥有每个可见尝试,直接 `ctx.llm.stream()` 调用仍是单次尝试。
- **流式工具调用参数只在调用结束时解析一次**——安装的 pi-ai 带有 [`patches/@earendil-works__pi-ai@0.87.1.patch`](../../../patches/@earendil-works__pi-ai@0.87.1.patch),它移除了每个流适配器中对整段累计参数 JSON 的逐 delta 重新解析(上游 [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265));未打补丁时,数 MB 的参数流会在事件循环上消耗 O(n²) CPU,并使进程内所有会话停滞。在 `toolcall_end` 之前,pi-ai partial 的工具调用 `arguments` 保持为 `{}`;本适配器只读取 delta 字符串与最终参数。每次升级 pi-ai 时都要重新应用或撤销该补丁。
- **流式工具调用参数只在调用结束时解析一次**——安装的 pi-ai 带有 [`patches/@earendil-works__pi-ai@1.0.0.patch`](../../../patches/@earendil-works__pi-ai@1.0.0.patch),它移除了每个流适配器中对整段累计参数 JSON 的逐 delta 重新解析(上游 [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265));未打补丁时,数 MB 的参数流会在事件循环上消耗 O(n²) CPU,并使进程内所有会话停滞。在 `toolcall_end` 之前,pi-ai partial 的工具调用 `arguments` 保持为 `{}`;本适配器只读取 delta 字符串与最终参数。每次升级 pi-ai 时都要重新应用或撤销该补丁。
<a id="dev-note"></a>
### 开发备注
+1 -1
View File
@@ -41,7 +41,7 @@
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"@earendil-works/pi-ai": "^0.87.1"
"@earendil-works/pi-ai": "^1.0.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
+28 -12
View File
@@ -12,7 +12,7 @@
* @module dsh-llm-pi-ai/catalog
*/
import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import { builtinProviders, getAllBuiltinModels, getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
import type {
AnthropicMessagesCompat,
@@ -166,37 +166,53 @@ export const CHAT_TEMPLATE_VARS = Object.keys(CHAT_TEMPLATE_VAR_GATE) as readonl
let providerIndex: Map<string, Provider> | undefined
/**
* Installed catalog providers by id, constructed once. Each entry owns the API
* implementations for its own models, which is why a catalog route reuses this
* provider instead of being rebuilt from parts.
* @returns the catalog provider index.
* Whether this adapter serves one installed catalog provider. It dispatches
* chat requests only, so a provider whose catalog lists models but no chat
* model (image-generation or classifier models alone) is not a catalog route;
* a provider whose catalog lists no models at all stays one.
* @param provider - the installed provider.
* @returns whether the provider ships a chat model or lists no models.
*/
function servesChat(provider: Provider): boolean {
const id = provider.id as BuiltinProvider
return getBuiltinModels(id).length > 0 || getAllBuiltinModels(id).length === 0
}
/**
* Installed catalog providers this adapter serves, by id, constructed once.
* Each entry owns the API implementations for its own models, which is why a
* catalog route reuses this provider instead of being rebuilt from parts.
* @returns the catalog provider index, in catalog order.
*/
function catalogProviders(): Map<string, Provider> {
providerIndex ??= new Map(builtinProviders().map(provider => [provider.id, provider]))
providerIndex ??= new Map(builtinProviders().filter(servesChat).map(provider => [provider.id, provider]))
return providerIndex
}
/**
* The installed catalog provider for one route, when pi-ai ships one.
* The installed catalog provider for one route, when pi-ai ships one this
* adapter serves.
* @param provider - provider route key.
* @returns the catalog provider, or `undefined` for a route pi-ai does not ship.
* @returns the catalog provider, or `undefined` for a route pi-ai does not ship
* or ships without a chat model.
*/
export function catalogProvider(provider: string): Provider | undefined {
return catalogProviders().get(provider)
}
/**
* Every provider route the installed pi-ai catalog ships.
* @returns the catalog provider ids.
* Every provider route the installed pi-ai catalog ships and this adapter
* serves: a provider whose catalog lists models but no chat model is left out.
* @returns the catalog provider ids, in catalog order.
*/
export function catalogProviderIds(): readonly string[] {
return getBuiltinProviders()
return [...catalogProviders().keys()]
}
/**
* The installed catalog models for one route, indexed by model id.
* @param provider - provider route key.
* @returns catalog models by id; empty for a route pi-ai does not ship.
* @returns catalog models by id; empty for a route {@link catalogProvider} does not return.
*/
export function catalogModels(provider: string): Map<string, Model<Api>> {
if (!catalogProviders().has(provider)) return new Map()
+16 -5
View File
@@ -16,19 +16,30 @@ import { recordKeyFor } from './auth.ts'
import type { PiAiAuthInjection } from './adapter.ts'
import { createModels } from './models.ts'
/**
* Catalog providers whose OAuth login this adapter does not offer. pi-ai's Sign
* in with ChatGPT (`openai`) identifies the installation to OpenAI with a
* stable UUID that the app supplies through `LoginOptions.getDeviceId`, and it
* refuses to start without one. The harness keeps no installation ID meant for
* a third-party account sign-in, so the route signs in with an API key.
*/
const UNSERVED_OAUTH: ReadonlySet<string> = new Set(['openai'])
/**
* The login methods one catalog provider offers.
*
* A method appears only when pi-ai can actually run it: `oauth` always carries
* a `login`, while an api-key method has one only when the provider collects
* its key interactively — which every installed one currently does, so a key is
* typed into pi-ai's own prompt rather than into the settings form.
* A method appears only when pi-ai can actually run it: `oauth` carries a
* `login` that runs without app-supplied login options except for the
* providers in {@link UNSERVED_OAUTH}, while an api-key method has one only
* when the provider collects its key interactively — which every installed one
* currently does, so a key is typed into pi-ai's own prompt rather than into
* the settings form.
* @param provider - the installed catalog provider, if pi-ai ships one.
* @returns its methods, most preferred first; empty when it offers no login.
*/
function loginMethods(provider: Provider | undefined): AuthorizationMethod[] {
const methods: AuthorizationMethod[] = []
const oauth = provider?.auth.oauth
const oauth = provider === undefined || UNSERVED_OAUTH.has(provider.id) ? undefined : provider.auth.oauth
if (oauth !== undefined) methods.push({ id: 'oauth', label: oauth.loginLabel ?? oauth.name })
const apiKey = provider?.auth.apiKey
if (apiKey?.login !== undefined) methods.push({ id: 'api-key', label: apiKey.name })
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest'
// The installed catalog is fixed, so the three provider postures the
// configurable-provider directory distinguishes are replaced at the pi-ai
// boundary: one ships a chat model, one ships only a classifier model, and one
// lists no model of any type (the posture of a deployment whose pi-ai stub has
// no catalog data, such as the browser-worker preview).
vi.mock('@earendil-works/pi-ai/providers/all', async importOriginal => ({
...await importOriginal<typeof import('@earendil-works/pi-ai/providers/all')>(),
builtinProviders: () => ['chat-provider', 'classifier-provider', 'uncataloged-provider'].map(id => ({ id })),
getBuiltinModels: (provider: string) => provider === 'chat-provider' ? [{ id: 'chat-model' }] : [],
getAllBuiltinModels: (provider: string) => {
if (provider === 'chat-provider') return [{ id: 'chat-model' }]
if (provider === 'classifier-provider') return [{ id: 'classifier-model', type: 'classifier' }]
return []
},
}))
import { catalogModels, catalogProvider, catalogProviderIds } from '../src/catalog.ts'
describe('catalog provider ids', () => {
it('drops a provider that lists only non-chat models and keeps one that lists none', () => {
expect(catalogProviderIds()).toEqual(['chat-provider', 'uncataloged-provider'])
})
it('answers no catalog provider or models for the provider it drops', () => {
expect(catalogProvider('classifier-provider')).toBeUndefined()
expect(catalogModels('classifier-provider').size).toBe(0)
expect(catalogProvider('uncataloged-provider')).toEqual({ id: 'uncataloged-provider' })
expect([...catalogModels('chat-provider').keys()]).toEqual(['chat-model'])
})
})
+17 -1
View File
@@ -7,10 +7,11 @@ const configurations = new WeakMap<Context, Awaited<ReturnType<typeof liveConfig
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import { getAllBuiltinModels, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import { AssistantMessageEventStream } from '@earendil-works/pi-ai/utils/event-stream'
import { normalizeContext } from '@earendil-works/pi-ai/utils/transcript'
import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-works/pi-ai'
import { catalogModels, catalogProvider } from '../src/catalog.ts'
import { resolveProfiles } from '../src/config.ts'
import { createModels, createProvider, getSupportedThinkingLevels } from '../src/models.ts'
import { buildProvider, supportedProtocols } from '../src/provider.ts'
@@ -1238,6 +1239,21 @@ describe('configurable-provider directory', () => {
expect(offered).toContain('openai')
})
it('leaves out an installed catalog route that ships no chat model', async () => {
const ctx = await harness({})
const offered = ctx.llm.listConfigurableProviders().map(entry => entry.provider)
// The installed catalog ships `typesafe` with classifier models only; with
// nothing to dispatch a chat request to, the route is not offered and is
// not a catalog route anywhere else either.
expect(getBuiltinProviders()).toContain('typesafe')
expect(getAllBuiltinModels('typesafe').length).toBeGreaterThan(0)
expect(getBuiltinModels('typesafe')).toEqual([])
expect(offered).not.toContain('typesafe')
expect(catalogProvider('typesafe')).toBeUndefined()
expect(catalogModels('typesafe').size).toBe(0)
})
it('lists a route a stored profile names as a catalog route, not a declared one', async () => {
// `declared` answers catalog membership, so a profile stored against a
// route pi-ai ships is not mislabelled as one this deployment invented.
@@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
import Loader, { type ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import LlmRuntime, { createMessage, createUserMessage, userAgent } from '@deepseek-ai/dsh-llm'
import AuthorizationService from '@deepseek-ai/dsh-authorization'
import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
import { profileComposition } from '../../../settings/settings/tests/profile-composition.ts'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
@@ -50,6 +51,8 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
' config:',
` path: ${JSON.stringify(join(root, '.credentials.yaml'))}`,
' debounceMs: 10',
'- id: authorization',
" name: '@deepseek-ai/dsh-authorization'",
'- id: llm-pi-ai',
" name: '@deepseek-ai/dsh-llm-pi-ai'",
'',
@@ -63,6 +66,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
const modules = new Map<string, unknown>([
['test-llm-service', LlmRuntime],
['@deepseek-ai/dsh-credentials-local', LocalCredentialProvider],
['@deepseek-ai/dsh-authorization', AuthorizationService],
['@deepseek-ai/dsh-llm-pi-ai', LlmPiAi],
])
const internal: ModuleLoaderV2 = {
@@ -110,6 +114,17 @@ describe('llm-pi-ai real dormant composition', () => {
expect(server.headers[0]?.authorization).toBe('Bearer key-from-store')
})
it('offers OpenAI sign-in by API key only, while other subscription logins stay', async () => {
const { ctx } = await loadComposition()
const methods = (provider: string): string[] | undefined => ctx.authorization.list()
.find(entry => entry.key === LlmPiAi.recordKeyFor(provider))?.methods.map(method => method.id)
// pi-ai's Sign in with ChatGPT needs an installation ID the harness does
// not supply, so the route offers only its key login.
expect(methods('openai')).toEqual(['api-key'])
expect(methods('anthropic')).toEqual(['oauth', 'api-key'])
})
it('uses settings-only route headers for model discovery', async () => {
vi.stubEnv('PI_COMPOSITION_KEY', '')
const server = await mockServer([{ body: JSON.stringify({ data: [{ id: 'acme-private' }] }) }])
@@ -20,6 +20,7 @@ vi.mock('../src/models.ts', async importOriginal => ({
}))
const { credentialStoreFrom, authContextFrom, recordKeyFor } = await import('../src/auth.ts')
const { catalogProvider } = await import('../src/catalog.ts')
const { registerPiAiFlows } = await import('../src/login.ts')
const CODEX = recordKeyFor('openai-codex')
@@ -96,6 +97,11 @@ describe('pi-ai login flows', () => {
// through its own prompt rather than leaving it to the settings form.
expect(offered.find(entry => entry.key === recordKeyFor('deepseek'))?.methods.map(one => one.id))
.toEqual(['api-key'])
// pi-ai ships Sign in with ChatGPT for `openai`, which needs an
// installation ID the harness does not supply; only the key login is offered.
expect(catalogProvider('openai')?.auth.oauth?.loginLabel).toBe('Sign in with ChatGPT')
expect(offered.find(entry => entry.key === recordKeyFor('openai'))?.methods.map(one => one.id))
.toEqual(['api-key'])
})
it('runs the pi-ai auth type the chosen method names', async () => {
+3 -2
View File
@@ -32,7 +32,8 @@ export const textEvents = [
/** Local provider stand-in: replays scripted behaviors per request. */
export async function mockServer(script: {
status?: number
events?: string[]
/** SSE frames: a string is sent as `data:` alone; a pair also names the frame's `event:`. */
events?: (string | { event: string; data: string })[]
body?: string
delayMs?: number
/** Keep the SSE response open after its scripted events until the client disconnects. */
@@ -79,7 +80,7 @@ export async function mockServer(script: {
if (!behavior.holdOpen) response.end()
return
}
response.write(`data: ${event}\n\n`)
response.write(typeof event === 'string' ? `data: ${event}\n\n` : `event: ${event.event}\ndata: ${event.data}\n\n`)
if (behavior.delayMs === undefined) writeNext()
else timer = setTimeout(writeNext, behavior.delayMs)
}
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { AssistantMessageEvent, Model } from '@earendil-works/pi-ai'
import { stream as streamAnthropic } from '@earendil-works/pi-ai/api/anthropic-messages'
import { stream as streamCompletions } from '@earendil-works/pi-ai/api/openai-completions'
import { stream as streamResponses } from '@earendil-works/pi-ai/api/openai-responses'
import { normalizeContext } from '@earendil-works/pi-ai/utils/transcript'
@@ -12,7 +13,7 @@ const args = { file_path: 'notes.md', content: 'x'.repeat(2048) }
const argsJson = JSON.stringify(args)
const fragments = argsJson.match(/.{1,7}/gs) ?? []
function model<A extends 'openai-completions' | 'openai-responses'>(api: A, baseUrl: string): Model<A> {
function model<A extends 'anthropic-messages' | 'openai-completions' | 'openai-responses'>(api: A, baseUrl: string): Model<A> {
return {
id: 'm', name: 'm', api, provider: 'test', baseUrl, reasoning: false, input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024,
@@ -61,6 +62,29 @@ describe('streamed tool-call arguments (patched pi-ai)', () => {
expect(final).toEqual(args)
})
it('anthropic-messages parses arguments once, at the end of the call', async () => {
const frame = (data: Record<string, unknown>): { event: string; data: string } =>
({ event: String(data['type']), data: JSON.stringify(data) })
const server = await mockServer([{ events: [
frame({ type: 'message_start', message: {
id: 'msg_1', type: 'message', role: 'assistant', model: 'm', content: [],
stop_reason: null, stop_sequence: null, usage: { input_tokens: 3, output_tokens: 1 },
} }),
frame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'toolu_1', name: 'write', input: {} } }),
...fragments.map(fragment => frame({
type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: fragment },
})),
frame({ type: 'content_block_stop', index: 0 }),
frame({ type: 'message_delta', delta: { stop_reason: 'tool_use', stop_sequence: null }, usage: { output_tokens: 5 } }),
frame({ type: 'message_stop' }),
] }])
const events = streamAnthropic(model('anthropic-messages', server.url), context, { apiKey: 'test-key' })
const { partials, final } = await collect(events)
expect(partials).toHaveLength(fragments.length)
expect(partials.every(partial => JSON.stringify(partial) === '{}')).toBe(true)
expect(final).toEqual(args)
})
it('openai-responses parses arguments once, at the end of the call', async () => {
const item = { type: 'function_call', call_id: 'call_1', id: 'fc_1', name: 'write' }
const server = await mockServer([{ events: [
@@ -1,8 +1,8 @@
diff --git a/dist/api/anthropic-messages.js b/dist/api/anthropic-messages.js
index e1538a5dfcf2225da9dc3ec622576564ee7c8530..abb8f990d680ee247708ca774a467fc5774e28b0 100644
index 106f2b9617fa3b810261e067a3c965f209907af3..d841d316172c82d5b1aebcfb2bde3e84b807ae0a 100644
--- a/dist/api/anthropic-messages.js
+++ b/dist/api/anthropic-messages.js
@@ -500,7 +500,6 @@ export const stream = (model, context, options) => {
@@ -553,7 +553,6 @@ export const stream = (model, context, options) => {
const block = blocks[index];
if (block && block.type === "toolCall") {
block.partialJson += event.delta.partial_json;
@@ -11,10 +11,10 @@ index e1538a5dfcf2225da9dc3ec622576564ee7c8530..abb8f990d680ee247708ca774a467fc5
type: "toolcall_delta",
contentIndex: index,
diff --git a/dist/api/bedrock-converse-stream.js b/dist/api/bedrock-converse-stream.js
index 6bc6312906ec3d75f31d190994d21c87f1d0767d..7ff68fb60c3c1d9e3e7d2d2df8d4619593a67335 100644
index a7f6d9cbe66546d5d418450809e92cf7289ce4a3..6fc5438c9ace36d8cb0f6bf3d077ebfa8281dcf5 100644
--- a/dist/api/bedrock-converse-stream.js
+++ b/dist/api/bedrock-converse-stream.js
@@ -453,7 +453,6 @@ function handleContentBlockDelta(event, blocks, output, stream) {
@@ -460,7 +460,6 @@ function handleContentBlockDelta(event, blocks, output, stream) {
}
else if (delta?.toolUse && block?.type === "toolCall") {
block.partialJson = (block.partialJson || "") + (delta.toolUse.input || "");
@@ -23,10 +23,10 @@ index 6bc6312906ec3d75f31d190994d21c87f1d0767d..7ff68fb60c3c1d9e3e7d2d2df8d46195
}
else if (delta?.reasoningContent) {
diff --git a/dist/api/mistral-conversations.js b/dist/api/mistral-conversations.js
index 67f9341a6f7d855dce25054fd48cd9077e8e04a1..afdb5c2c873abb72ca8011b7e3f06a230dbe3e8a 100644
index fbcc5e25ee6752a9c5461b41fa8544a090a17b3f..f540825c4f934f342200db72190599fe95f01d0c 100644
--- a/dist/api/mistral-conversations.js
+++ b/dist/api/mistral-conversations.js
@@ -543,7 +543,6 @@ async function consumeChatStream(model, output, stream, mistralStream) {
@@ -554,7 +554,6 @@ async function consumeChatStream(model, output, stream, mistralStream, onProvide
? toolCall.function.arguments
: JSON.stringify(toolCall.function.arguments || {});
block.partialArgs = (block.partialArgs || "") + argsDelta;
@@ -35,10 +35,10 @@ index 67f9341a6f7d855dce25054fd48cd9077e8e04a1..afdb5c2c873abb72ca8011b7e3f06a23
type: "toolcall_delta",
contentIndex: toolBlocksByKey.get(key),
diff --git a/dist/api/openai-completions.js b/dist/api/openai-completions.js
index 48464f2bf56b9369d5c202e4f02fc4f13d35b412..d829f5e8e56e788d96fa7a82c0e3cfaef3fc1f63 100644
index eecd83c753e3b758b8e44628f560b862f8252839..201ad4da96e33bcac9446093c804794d6e64fc82 100644
--- a/dist/api/openai-completions.js
+++ b/dist/api/openai-completions.js
@@ -453,7 +453,6 @@ export const stream = (model, context, options) => {
@@ -438,7 +438,6 @@ export const stream = (model, context, options) => {
if (toolCall.function?.arguments) {
delta = toolCall.function.arguments;
block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments;
@@ -47,10 +47,10 @@ index 48464f2bf56b9369d5c202e4f02fc4f13d35b412..d829f5e8e56e788d96fa7a82c0e3cfae
else if (toolCall.custom?.input) {
const nextInput = getCustomToolCallInput(block) + toolCall.custom.input;
diff --git a/dist/api/openai-responses-shared.js b/dist/api/openai-responses-shared.js
index 43e463dbfd1e6cc437ebc680468036f47678de26..f4259772e289c326697ea7f2ece25954bfb5a520 100644
index f413fe5f2a1abcac39c38bbd4a77b38c349a5918..108f599ae1d985d2f465e8cdf99ce0624acea6aa 100644
--- a/dist/api/openai-responses-shared.js
+++ b/dist/api/openai-responses-shared.js
@@ -546,7 +546,6 @@ export async function processResponsesStream(openaiStream, output, stream, model
@@ -548,7 +548,6 @@ export async function processResponsesStream(openaiStream, output, stream, model
if (!slot || slot.block.partialJson === undefined)
continue;
slot.block.partialJson += event.delta;
@@ -59,7 +59,7 @@ index 43e463dbfd1e6cc437ebc680468036f47678de26..f4259772e289c326697ea7f2ece25954
}
else if (event.type === "response.function_call_arguments.done") {
diff --git a/dist/api/pi-messages.js b/dist/api/pi-messages.js
index 5b9cb1680c47083a9a36eefa93124c67c750fd42..dc5a43ebd801fa6d4b7897c3739b588d4d571d3a 100644
index 7da1c45de8a106a68411cf79bec75f553534a1c7..296d131b59b1cf75f2516c39e85bba72972b2c17 100644
--- a/dist/api/pi-messages.js
+++ b/dist/api/pi-messages.js
@@ -155,8 +155,6 @@ function createEventConverter(model) {
+35 -15
View File
@@ -10,7 +10,7 @@ overrides:
'@deepseek-ai/schemastery': link:vendor/schemastery
patchedDependencies:
'@earendil-works/pi-ai@0.87.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f
'@earendil-works/pi-ai@1.0.0': 3b2f85a92bded8a4fd06652620a93f24a7562f5e082a8dccec09ddd76e483e0e
'@electron/[email protected]': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861
'@fortune-sheet/[email protected]': 8791a230763dbf853fd65d627c68c31de37209992851377855e16cf08ea70532
'@fortune-sheet/[email protected]': 2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37
@@ -9945,8 +9945,8 @@ importers:
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
'@earendil-works/pi-ai':
specifier: ^0.87.1
version: 0.87.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/[email protected]([email protected]))([email protected])([email protected])([email protected])
specifier: ^1.0.0
version: 1.0.0(patch_hash=3b2f85a92bded8a4fd06652620a93f24a7562f5e082a8dccec09ddd76e483e0e)(@aws-sdk/[email protected])(@modelcontextprotocol/[email protected]([email protected]))(@smithy/[email protected])([email protected])([email protected])([email protected])([email protected])
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
@@ -15378,13 +15378,13 @@ packages:
search-insights:
optional: true
'@earendil-works/pi-ai@0.87.1':
resolution: {integrity: sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA==}
'@earendil-works/pi-ai@1.0.0':
resolution: {integrity: sha512-3/W1vdDaVtpeMd23ElvJC12HLA5yS/BGqqcXF+0SK082dN7cbgNcCwguTBRBC258Ke8SzSvUW1B75iAf8w8IxA==}
engines: {node: '>=22.19.0'}
hasBin: true
'@earendil-works/pi-telemetry@0.87.1':
resolution: {integrity: sha512-MC6TRQH5lgMXpcN+Vku2WMI2T8BsiUPzMQHGo81uqFZD3/9O79WWJAysEDGuzduP6R4tvtgwMLwmqIxynM10JQ==}
'@earendil-works/pi-telemetry@1.0.0':
resolution: {integrity: sha512-WjNBj5TYIiPZFQEz2WlULcDwPLaKwIlmsKjVeYM+LJSbnSp38kWsHUJysIDGnu23IcLbKoPtywsvYfUJCeZePA==}
engines: {node: '>=22.19.0'}
'@electron-internal/[email protected]':
@@ -20692,12 +20692,25 @@ packages:
resolution: {integrity: sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==}
engines: {node: '>=20'}
openai@6.40.0:
resolution: {integrity: sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==}
openai@7.19.0:
resolution: {integrity: sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==}
engines: {node: '>=22.0.0'}
peerDependencies:
ws: ^8.18.0
'@aws-sdk/credential-provider-node': '>=3.972.0 <4'
'@smithy/hash-node': '>=4.3.0 <5'
'@smithy/signature-v4': '>=5.4.0 <6'
undici: '>=5 <9'
ws: ^8.21.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
'@aws-sdk/credential-provider-node':
optional: true
'@smithy/hash-node':
optional: true
'@smithy/signature-v4':
optional: true
undici:
optional: true
ws:
optional: true
zod:
@@ -22900,28 +22913,32 @@ snapshots:
transitivePeerDependencies:
- '@algolia/client-search'
'@earendil-works/pi-ai@0.87.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/[email protected]([email protected]))([email protected])([email protected])([email protected])':
'@earendil-works/pi-ai@1.0.0(patch_hash=3b2f85a92bded8a4fd06652620a93f24a7562f5e082a8dccec09ddd76e483e0e)(@aws-sdk/[email protected])(@modelcontextprotocol/[email protected]([email protected]))(@smithy/[email protected])([email protected])([email protected])([email protected])([email protected])':
dependencies:
'@anthropic-ai/sdk': 0.124.0([email protected])
'@aws-sdk/client-bedrock-runtime': 3.1127.0
'@earendil-works/pi-telemetry': 0.87.1
'@earendil-works/pi-telemetry': 1.0.0
'@google/genai': 2.21.0(@modelcontextprotocol/[email protected]([email protected]))
'@smithy/node-http-handler': 4.12.1
http-proxy-agent: 9.1.0([email protected])
https-proxy-agent: 9.1.0([email protected])
openai: 6.40.0([email protected])([email protected])
openai: 7.19.0(@aws-sdk/[email protected])(@smithy/[email protected])([email protected])([email protected])([email protected])
partial-json: 0.1.7
typebox: 1.3.27
transitivePeerDependencies:
- '@aws-sdk/credential-provider-node'
- '@modelcontextprotocol/sdk'
- '@smithy/hash-node'
- '@smithy/signature-v4'
- bufferutil
- kerberos
- supports-color
- undici
- utf-8-validate
- ws
- zod
'@earendil-works/pi-telemetry@0.87.1': {}
'@earendil-works/pi-telemetry@1.0.0': {}
'@electron-internal/[email protected]': {}
@@ -28268,8 +28285,11 @@ snapshots:
powershell-utils: 0.2.1
wsl-utils: 1.0.0
openai@6.40.0([email protected])([email protected]):
openai@7.19.0(@aws-sdk/[email protected])(@smithy/[email protected])([email protected]0.0)([email protected])([email protected]):
optionalDependencies:
'@aws-sdk/credential-provider-node': 3.972.84
'@smithy/signature-v4': 5.7.4
undici: 8.10.0
ws: 8.21.0
zod: 4.4.3
+3 -3
View File
@@ -64,8 +64,8 @@ minimumReleaseAgeExclude:
- '@deepseek-ai/[email protected]'
# Fresh pi-ai releases carry the model catalog updates that are the whole
# point of bumping it; waiting out the release age would defeat that.
- '@earendil-works/pi-ai@0.87.1'
- '@earendil-works/pi-telemetry@0.87.1'
- '@earendil-works/pi-ai@1.0.0'
- '@earendil-works/pi-telemetry@1.0.0'
- [email protected]
- [email protected]
- [email protected]
@@ -98,7 +98,7 @@ minimumReleaseAgeExclude:
- '@trycua/[email protected]'
- '@trycua/[email protected]'
patchedDependencies:
'@earendil-works/pi-ai@0.87.1': patches/@earendil-works__pi-ai@0.87.1.patch
'@earendil-works/pi-ai@1.0.0': patches/@earendil-works__pi-ai@1.0.0.patch
'@electron/[email protected]': patches/@[email protected]
'@fortune-sheet/[email protected]': patches/@[email protected]
'@fortune-sheet/[email protected]': patches/@[email protected]