diff --git a/docs/pi-extensions-design-appendix-zh.md b/docs/pi-extensions-design-appendix-zh.md new file mode 100644 index 000000000..58fc8ea6b --- /dev/null +++ b/docs/pi-extensions-design-appendix-zh.md @@ -0,0 +1,85 @@ +# Pi Extensions 一等支持设计附录 + +> 状态:仅设计,不含实现。主工程不实现 extensions;themes 明确不在范围内。 +> authority:任何上游路径、加载顺序、启用规则或 tool 组合语义,在实施前都必须 +> 由 pin `ab366ebe94cacd419d986be454f12b1b9913aaca` 的 oracle 或 +> `scripts/pi-transport-capture.mjs` 实际执行确认,本文不以源码阅读代替证据。 + +## 目标 + +未来让用户在 cc-switch 中观察、导入、启用和停用 Pi extensions,同时满足: + +1. 原生文件/目录是真相,`exists = active`,不制造与 Pi 分叉的 enabled 影子状态; +2. 只接管 cc-switch 明确拥有或用户显式、可验证采用的内容; +3. extension 提供的 tools 与 pinned core tools 分开展示,不伪装为 MCP server; +4. 所有写操作可并发检测、可补偿,portable import 不覆盖目标设备的未知资产; +5. 复用前置 C inspection 与当前 Skill/Prompt 的共享文件、fingerprint、ownership + 和协调器原语。 + +## 模块边界 + +```text +PiExtensionInspector (只读、oracle 驱动) + │ + ├── NativeExtensionObservation + │ path / fingerprint / manifest / contributed tools + │ validity / reasons / ownership + │ + └── PiExtensionCoordinator (唯一写入口) + ├── exact-content adoption + ├── CAS + atomic replace + ├── ownership ledger transaction + ├── catalog epoch / UI invalidation + └── compensation on partial failure +``` + +- `PiExtensionInspector` 只负责原生观察和结构化诊断;不得写数据库或文件。 +- `PiExtensionCoordinator` 是唯一写入口;命令、deeplink、portable reconcile 与 + UI mutation 都调用它,禁止各自复制目录。 +- 通用 `shared_file`、Skill tree fingerprint 与 ownership ledger 可复用; + extension-specific manifest/加载规则必须先新增捕获向量,不能借 Skill 规则猜测。 +- gateway 只消费 coordinator 发布后的 immutable runtime snapshot;extension + 不能在请求中途直接改 candidate/header 计划。 + +## 状态模型 + +建议公开三个正交维度: + +- `discovery`: `absent | active | invalid | unknown`,只来自 native observation; +- `ownership`: `external | adoptable_exact | managed | conflict`; +- `capability`: `inspectable | manageable | unsupported | unknown`。 + +不得增加独立 `enabled` 布尔值。用户点击“停用”时,语义是对受管原生资产执行可逆 +移除;外部资产只能显式采用后再管理。内容变化导致 fingerprint 不匹配时进入 +`conflict`,不得覆盖。 + +## Tools 与 MCP + +- capture 已确认 pinned core tool inventory 为 + `bash/edit/find/grep/ls/read/write`;未来 capture 应分别记录每个 extension + 注入前后的 tool inventory 与来源。 +- UI 将 tools 按 `core` / `extension:` 分组,并展示冲突与覆盖次序的实测结果。 +- MCP 页面仍不为 Pi 建虚假 registry。即使某个 extension 通过自身机制连接外部 + tool,也属于 extension capability,除非未来 pinned Pi 真正提供 MCP registry + 且由新证据和契约明确升级。 + +## Portable 与冲突策略 + +- 备份只携带 cc-switch 拥有的 extension 描述、内容 hash 和期望状态,不携带绝对 + 目录、设备 token 或未知外部目录。 +- 导入先观察目标设备;missing 可部署,exact 可采用,different 必须 conflict, + 绝不“最后写入者获胜”。 +- 多 extension 贡献同名 tool、command 或资源时 fail-closed;只有 oracle/capture + 证明 Pi 的确定 precedence 且产品明确展示该覆盖时,才允许自动解析。 + +## 实施前验收 + +1. 扩展 transport capture:发现路径、空/损坏 manifest、启停、重复 ID、资源覆盖、 + tool inventory、相对路径和 symlink 负例。 +2. 冻结 schema/oracle provenance,建立 lossless raw observation;未知形状为 + `unknown`,不得整目录连坐隐藏合法兄弟。 +3. 服务级测试覆盖显式采用、并发 CAS loser、写后补偿、portable reconcile、 + 外部冲突、目录越界与 UI 的 `exists = active`。 +4. 中英文 UI 与可访问性完成后,再进入独立实现与盲审。 + +Themes 不与 extensions 共用该项目:其资源语义、预览与安全面另行立项。 diff --git a/docs/pi-main-project-zh.md b/docs/pi-main-project-zh.md index a86a6b2df..d4b2c3f97 100644 --- a/docs/pi-main-project-zh.md +++ b/docs/pi-main-project-zh.md @@ -46,3 +46,99 @@ 不必对齐红绿账本,不必按固定轮次做盲审,偏离不必硬停上报。 遇到本文与既有契约冲突、或需要用户拍板的产品取舍(功能取舍、范围增删), 才上报;其余自行裁量,完成后报终态 SHA。 + +## 实现 authority 与可复现实证 + +- 唯一 Pi implementation pin: + `ab366ebe94cacd419d986be454f12b1b9913aaca`。主工程没有沿用历史文档中的 + 旧 pin,也没有从远端观察值推导行为。 +- `scripts/generate-pi-native-oracle.mjs` 负责 schema/composer 的冻结 oracle; + `scripts/pi-transport-capture.mjs` 负责需要真实执行 Pi 才能确认的 transport、 + compat、原生资源与 CLI 行为。两者都先校验上述 commit,pin 不符即拒绝运行。 +- 主工程捕获命令: + `PI_CHECKOUT= node scripts/pi-transport-capture.mjs`。 + 捕获直接执行 Pi,而非阅读上游源码推断。 +- 捕获的发行元数据来自 `packages/coding-agent/package.json`,该文件在 pin 上的 + SHA-256 为 + `e02deae1cec07035807436c1864c88342e2f7d49050d03b858a3719f0c7aedbf`: + 包名 `@earendil-works/pi-coding-agent`、可执行文件 `pi`、配置目录 `.pi`; + `parseArgs(["--version"])` 与带空格绝对路径的 + `parseArgs(["--session", path])` 均由捕获实际执行。 +- 同一捕获实际发出了四族请求并记录最终 URL/headers。Anthropic OAuth 凭证 + (`sk-ant-oat` 子串)最终为 `Authorization: Bearer `, + `anthropic-beta` 含 `claude-code-20250219,oauth-2025-04-20`,且主工程策略 + 强制不并发 `x-api-key`。OpenAI Responses/Completions、Google 与普通 + Anthropic key 的头部矩阵也在同一输出中。 + +## Hermes 先例逐项对照账本 + +| 通路 | Pi 终态 | Hermes 差异或不适用理由 | +|---|---|---| +| App 注册、切换器、图标、可见性 | `AppType::Pi` 贯穿 Rust/IPC/TS,旧设置缺字段时默认可见,可独立隐藏 | 无差异 | +| 供应商与模型 | 独立 Pi 表单无损保留 provider/model 的未知 JSON;支持 provider/model 级 `api`、`baseUrl`、headers、`authHeader`、credential、cost/compat 等 pinned 字段;原生 catalog 使用已认证 inspection 导入 | Hermes 的 Web UI overlay/只读 provider 是 Hermes 自身所有权模型,Pi 使用共享 `models.json`,不套用 overlay | +| 新用户首用 | 创建第一位 Pi provider 时,在数据库、完整初始 endpoints 与原生 catalog 同一协调边界内发布,并把其第一模型设为 native default;随后可直接切换/代理 | 无需先手工导入或另开 native 文件 | +| 原生 catalog 与当前状态 | 复用前置 C 的三层 inspection;展示 raw/managed/composer/gateway 状态;可用 fingerprint CAS 导入;当前 provider/model 直接读 native defaults | 不以数据库 current 字段制造第二份“当前”状态 | +| 端点 | provider 主端点与多个 typed custom endpoints 均可增删、测速;membership 与 provider hydration 一致;网关按主端点及有序候选展开 | Hermes 的 additive provider 不具备该网关数据面 | +| 切换、代理与故障转移 | Pi 有独立 loopback route/token、catalog epoch、健康状态、重试/熔断和 failover 队列;候选必须保持 wire family 与协议身份兼容;不可预测协议表达式只允许单次 direct attempt,不重放 | Hermes 当前没有 cc-switch gateway handler;Pi 对照的是已有完整代理 app 的安全边界 | +| OAuth 数据面 | 完整实现 Anthropic OAuth 的 Bearer + oauth beta,强制删除 `x-api-key`;deferred credential 在物化后分类 | 由 request-capture 实证;不再停留在前置 C 的 DirectOnly | +| Skills | Pi 进入统一 Skills UI;期望状态、原生发现、受管 ownership 分离;显式 native import 只在内容完全相同时接管;部署、删除、导入及 portable sync 有锁、CAS、事务补偿 | Pi 的原生目录是部署目标,不能复用其他 app 的“复制后即视为拥有”假设 | +| Prompts | Prompt 库投影 `AGENTS.md`;同时原生管理 `SYSTEM.md`、`APPEND_SYSTEM.md` 与 `prompts/*.md`;文件存在即 active,直接编辑空白值会拒绝并要求显式删除 | Hermes 的 prompt 通路仍可复用;Pi 额外公开 pinned 原生资源,不制造 enabled 影子字段 | +| Sessions | 扫描 pinned v1–v3 JSONL tree、只显示 active branch,支持详情、删除、终端恢复;遵守环境/设置/default sessionDir;相对 sessionDir 明示需要项目 cwd,不伪装为空列表 | Pi 的 tree/branch 与 Hermes session 格式不同,使用独立 parser 但复用统一 Session UI/安全入口 | +| MCP | **不适用**:pinned Pi core 没有原生 MCP registry,UI 不展示虚假 MCP 状态,服务入口结构化拒绝;capture 的 core tool inventory 为 `bash/edit/find/grep/ls/read/write` | Hermes 有原生 MCP registry;Pi extensions 可贡献 tools,但不等同 MCP。未来方案见 `docs/pi-extensions-design-appendix-zh.md` | +| 深链 | provider 深链支持 Pi provider key、模型、api、端点及 credential,走真实 `ProviderService::add`;prompt 深链走统一库与 Pi 文件所有权链 | 未识别 Pi API 保持 opaque,不用名称猜协议 | +| 设置与目录 | 设置页可发现/选择 Pi 配置目录,支持 `PI_CODING_AGENT_DIR`、WSL 路径展示、安装检测、网关/故障转移参数与凭证轮换;本地 gateway token 不下发前端 | 配置目录和 token 是 device-local,不写进 portable DB | +| 使用统计 | 四族 response/SSE 使用量进入统一日志、价格、筛选与 dashboard;每请求携带 input-token semantics,不能用 `app_type` 猜;Pi 混合协议的 cache-write 总数标为“部分可得” | Hermes 无 gateway usage;Pi 的单一 app 桶可能混合四族,不能显示误导性的确定 0 | +| 导入导出与远端同步 | DB provider/prompt/skill 期望状态可 portable;恢复后通过 catalog/prompt/skill reconciliation 与本机原生状态对齐;设备目录、native defaults、token、session 不跨机复制 | 原生资产属于设备/外部事实,不能塞进 SQL/binary 备份制造影子副本 | +| Profile 切换器 | **不适用**:现有项目 Profile 仅定义 Claude 组与 Codex 组,Hermes 本身也不属于任何 Profile scope;Pi 页面同样不展示 | 若未来产品定义新的跨 app Profile scope,应单独设计原生文件事务,不能暗自归入 Claude/Codex | +| Hermes Memory/Web UI | **不适用**:这是 Hermes 独有 daemon/API;Pi 的长期上下文入口是 `AGENTS.md` 与原生 instruction files,已在 Prompts 面公开 | 不伪造不存在的 Pi daemon 或 Web UI | +| 原生“一键导入”按钮 | Pi 不调用 generic `import_default`;改为 certified native catalog 中逐 entry inspection + fingerprint CAS 导入 | 这是更严格的等价通路,不是功能缺失 | +| Extensions / Themes | extensions 本工程只交设计附录;themes 明确排除 | 用户已裁定范围 | + +## 原生状态与 portable 状态边界 + +1. `models.json` 的读取、composition 与 gateway capability 只走前置 C 已认证链。 + 写入统一经 Pi catalog coordinator,协调 DB aggregate、typed endpoints、 + native document、default provider/model、epoch 与失败补偿;legacy live-sync + 不得旁路写它。 +2. `AGENTS.md`、`SYSTEM.md`、`APPEND_SYSTEM.md`、prompt templates、Skills + 与 Sessions 直接观察真实文件;所有 UI active 判断均来自 `exists` 或实际发现。 + revision/fingerprint 只用于并发保护,不是第二套 enabled 状态。 +3. portable import 恢复的是 cc-switch 拥有的 provider、prompt 与 Skill 期望状态, + 随后与本机 native 事实 reconcile;不会跨设备覆盖目录、凭证 token、native + defaults 或会话。 +4. Pi provider card 的“切换”选择该 provider 的第一模型;多模型的明确选择在 + native catalog panel 完成,并写回同一 native default,不维护每 provider 的 + 隐藏 last-model 状态。 + +## 完成审查约束 + +- 主工程盲审最多七轮;每轮冻结精确 SHA/range,两位 fresh、互相独立、只读 + reviewer。已审 clean SHA 作为下一轮增量基线;契约新增条款的作用域另做定向 + 全域调用点扫描。 +- P0/P1 finding 必须修复;P2 由主审按可达性、影响面、数据/安全后果与发生概率 + 独立裁决,只有低影响且极少触发的边界才可保留,并在最终报告列出理由。 +- 完成前做一次完整组件通读;零 validated P0/P1、相关验证全绿后才可交付。 + +## Scope audit(主工程预审检查点) + +以主工程基线 `26a95aeb059235612c665d6b6bcd92fde9c3572e` 计,预审工作树为 +137 个文件、约 +13.7k 净行,触发历史 90 文件/12k 净行审计线。审计结论为 +**范围大但未越界**: + +- Rust 70 文件:Pi catalog/typed endpoints、共享文件与 ownership、gateway/runtime/ + usage、prompts/skills/sessions,以及把 `AppType::Pi` 接进现有 command/service/ + startup/sync 通路;其中新增的大文件按 catalog、transport、runtime、session、 + deployment 分开,避免一个全知模块。 +- 前端 52 文件:3 个 Pi 专用 panel/form、typed IPC/query、既有导航/设置/代理/ + 使用统计接线和中英文 i18n;没有复制第二套 app shell。 +- 前端测试 12 文件:专用表单/prompt/usage 测试及既有跨 app matrix 增量。 +- 证据与文档 3 文件:transport capture、本文账本、extensions 设计附录。 +- `schema.rs`、`migration.rs`、`backup.rs`、三份认证套件及七个 pinned fixtures + 均零改动;restore、themes 与 extensions 实现零文件。 + +压缩方案也做过评估:把 Pi 强塞进 legacy live-sync、通用 additive provider、 +固定 app-type usage 语义或无 ledger Skill 复制可以减少文件,却会重新引入已经 +认证过的 ownership/原生真实/协议分类错误;把 UI 与领域代码揉成少数巨型文件只 +会减少文件数而增加审查风险。当前拆分复用了 provider write、proxy server、 +Prompt/Skill/Session UI、同步锁和统计基础设施,新增代码只承载 Pi 不同的原生语义。 +因此不为满足计数机械缩并,也不扩展到契约外功能。 diff --git a/scripts/pi-transport-capture.mjs b/scripts/pi-transport-capture.mjs index cda83ec91..3ab5d5806 100644 --- a/scripts/pi-transport-capture.mjs +++ b/scripts/pi-transport-capture.mjs @@ -21,9 +21,16 @@ import { createServer } from "node:http"; import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { createRequire } from "node:module"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; const PI = process.env.PI_CHECKOUT; @@ -45,6 +52,17 @@ if (piCommit !== EXPECTED_PI_COMMIT) { const requireFromPi = createRequire(join(PI, "package.json")); const { buildSync, version: esbuildVersion } = requireFromPi("esbuild"); +const codingAgentPackagePath = join(PI, "packages/coding-agent/package.json"); +const codingAgentPackageBytes = readFileSync(codingAgentPackagePath); +const codingAgentPackage = JSON.parse(codingAgentPackageBytes.toString("utf8")); +const distributionMetadata = { + source: "packages/coding-agent/package.json", + sha256: createHash("sha256").update(codingAgentPackageBytes).digest("hex"), + name: codingAgentPackage.name, + version: codingAgentPackage.version, + bin: codingAgentPackage.bin, + piConfig: codingAgentPackage.piConfig, +}; const ANTHROPIC_SSE = 'event: message_start\ndata: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"m","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}\n\n' + @@ -77,11 +95,40 @@ const aiShimPath = join(harnessDirectory, "pi-ai-shim.mjs"); const compatShimPath = join(harnessDirectory, "pi-ai-compat-shim.mjs"); writeFileSync( aiShimPath, - 'export function lazyStream() { throw new Error("transport shim must not execute during compat capture"); }\n', + [ + 'export function lazyStream() { throw new Error("transport shim must not execute during compat capture"); }', + "let uuidSequence = 0;", + 'export function uuidv7() { uuidSequence += 1; return `00000000-0000-7000-8000-${String(uuidSequence).padStart(12, "0")}`; }', + "export class EventStream {}", + "export class ModelsError extends Error {}", + "export function validateToolArguments() { return undefined; }", + 'export function contentText(value) { return typeof value === "string" ? value : ""; }', + 'export function retryAssistantCall() { throw new Error("resource capture must not call AI"); }', + "export function parseStreamingJson() { return undefined; }", + "export function modelsAreEqual(left, right) { return left === right; }", + "export function createModels() { return {}; }", + "export function getBuiltinModelDataGeneratedAt() { return undefined; }", + "export function builtinProviders() { return []; }", + "export function radiusProvider() { return undefined; }", + "", + ].join("\n"), ); writeFileSync( compatShimPath, - 'export function getApiProvider() { throw new Error("transport shim must not execute during compat capture"); }\n', + [ + 'export function getApiProvider() { throw new Error("transport shim must not execute during compat capture"); }', + "export function clampThinkingLevel(value) { return value; }", + "export async function cleanupSessionResources() {}", + "export function getSupportedThinkingLevels() { return []; }", + "export function isContextOverflow() { return false; }", + "export function isRetryableAssistantError() { return false; }", + "export function modelsAreEqual(left, right) { return left === right; }", + "export function resetApiProviders() {}", + 'export function streamSimple() { throw new Error("resource capture must not call AI"); }', + 'export function stream() { throw new Error("resource capture must not call AI"); }', + 'export function completeSimple() { throw new Error("resource capture must not call AI"); }', + "", + ].join("\n"), ); const entryPoint = join(harnessDirectory, "entry.mjs"); writeFileSync( @@ -90,8 +137,14 @@ writeFileSync( `export { streamSimple as anthropicMessages } from "${PI}/packages/ai/src/api/anthropic-messages.ts";`, `export { streamSimple as openaiResponses } from "${PI}/packages/ai/src/api/openai-responses.ts";`, `export { streamSimple as openaiCompletions } from "${PI}/packages/ai/src/api/openai-completions.ts";`, + `export { streamSimple as googleGenerativeAi } from "${PI}/packages/ai/src/api/google-generative-ai.ts";`, `export { composeModelProvider } from "${PI}/packages/coding-agent/src/core/provider-composer.ts";`, `export { resolveConfigValueOrThrow } from "${PI}/packages/coding-agent/src/core/resolve-config-value.ts";`, + `export { loadSkills } from "${PI}/packages/coding-agent/src/core/skills.ts";`, + `export { loadPromptTemplates } from "${PI}/packages/coding-agent/src/core/prompt-templates.ts";`, + `export { SessionManager } from "${PI}/packages/coding-agent/src/core/session-manager.ts";`, + `export { parseArgs } from "${PI}/packages/coding-agent/src/cli/args.ts";`, + `export { createAllToolDefinitions } from "${PI}/packages/coding-agent/src/core/tools/index.ts";`, ].join("\n"), ); const bundlePath = join(harnessDirectory, "bundle.mjs"); @@ -111,16 +164,57 @@ buildSync({ }); const adapters = await import(pathToFileURL(bundlePath).href); +// ResourceLoader pulls the complete coding-agent resource graph. Bundle the +// real pinned ResourceLoader separately with broad AI stubs; the captured +// instruction behavior remains real while unrelated generated model data is +// kept outside this resource-only probe. +const resourceEntryPoint = join(harnessDirectory, "resource-entry.mjs"); +const resourceBundlePath = join(harnessDirectory, "resource-bundle.mjs"); +writeFileSync( + resourceEntryPoint, + `export { DefaultResourceLoader } from "${PI}/packages/coding-agent/src/core/resource-loader.ts";\n`, +); +buildSync({ + entryPoints: [resourceEntryPoint], + bundle: true, + platform: "node", + format: "esm", + outfile: resourceBundlePath, + external: ["node:*"], + packages: "external", + alias: { + "@earendil-works/pi-ai/providers/all": aiShimPath, + "@earendil-works/pi-ai/oauth": aiShimPath, + "@earendil-works/pi-ai": aiShimPath, + "@earendil-works/pi-ai/compat": compatShimPath, + }, + logLevel: "silent", +}); +const resourceAdapters = await import(pathToFileURL(resourceBundlePath).href); + const API_BY_ADAPTER = { anthropicMessages: "anthropic-messages", openaiResponses: "openai-responses", openaiCompletions: "openai-completions", + googleGenerativeAi: "google-generative-ai", }; /** 每个用例只改变凭证与显式 header,其余保持最小合法模型。 */ const CASES = [ ["anthropicMessages", "plain-key", "sk-ant-api03-plain", {}], ["anthropicMessages", "oauth-token", "sk-ant-oat01-token", {}], + [ + "anthropicMessages", + "oauth-with-explicit-x-api-key", + "sk-ant-oat01-token", + { "x-api-key": "explicit-secret" }, + ], + [ + "anthropicMessages", + "oauth-with-explicit-authorization", + "sk-ant-oat01-token", + { authorization: "Bearer configured" }, + ], [ "anthropicMessages", "explicit-x-api-key", @@ -153,6 +247,13 @@ const CASES = [ "synthesized-secret", { "x-api-key": "explicit-secret" }, ], + ["googleGenerativeAi", "plain-key", "google-plain", {}], + [ + "googleGenerativeAi", + "explicit-x-goog-api-key", + "google-synthesized", + { "x-goog-api-key": "google-explicit" }, + ], ]; const results = []; @@ -193,6 +294,7 @@ for (const [adapter, label, apiKey, headers] of CASES) { adapter: API_BY_ADAPTER[adapter], case: label, requestSent: Boolean(request), + requestUrl: request?.url, error: request ? undefined : error, authHeaders: request ? Object.fromEntries( @@ -243,6 +345,38 @@ const compatProvider = adapters.composeModelProvider( ); const compatSpread = compatProvider.getModels()[0].compat; +const minimalProvider = adapters.composeModelProvider( + "minimal-provider", + undefined, + { + getProvider(providerId) { + return providerId === "minimal-provider" + ? { + name: "Minimal provider", + api: "openai-responses", + baseUrl: "https://minimal.example/v1", + apiKey: "literal", + models: [{ id: "minimal-model", name: "Minimal model" }], + } + : undefined; + }, + }, + undefined, +); +const minimalModel = minimalProvider.getModels()[0]; +const minimalProviderComposition = { + id: minimalModel.id, + name: minimalModel.name, + provider: minimalModel.provider, + api: minimalModel.api, + baseUrl: minimalModel.baseUrl, + reasoning: minimalModel.reasoning, + input: minimalModel.input, + cost: minimalModel.cost, + contextWindow: minimalModel.contextWindow, + maxTokens: minimalModel.maxTokens, +}; + function jsonSafeJavaScriptValue(value) { if (typeof value === "string") { const codeUnits = Array.from({ length: value.length }, (_, index) => @@ -357,6 +491,262 @@ const resolverCases = resolverInputs.map((input) => { } }); +// Execute Pi's real discovery entry point. The directories are deliberately +// created in reverse order: a deterministic winner must come from Pi's +// discovery ordering, not filesystem insertion order. +const skillProbeRoot = join(harnessDirectory, "skill-discovery"); +const skillAgentDir = join(skillProbeRoot, "agent"); +const skillProjectDir = join(skillProbeRoot, "project"); +for (const directory of [ + skillProjectDir, + join(skillAgentDir, "skills", "b-second"), + join(skillAgentDir, "skills", "a-first"), +]) { + mkdirSync(directory, { recursive: true }); +} +writeFileSync( + join(skillAgentDir, "skills", "b-second", "SKILL.md"), + "---\nname: duplicate\ndescription: second\n---\nsecond\n", +); +writeFileSync( + join(skillAgentDir, "skills", "a-first", "SKILL.md"), + "---\nname: duplicate\ndescription: first\n---\nfirst\n", +); +const skillDiscovery = jsonSafeJavaScriptValue( + await adapters.loadSkills({ + cwd: skillProjectDir, + agentDir: skillAgentDir, + skillPaths: [], + includeDefaults: true, + }), +); + +const promptAgentDir = join(harnessDirectory, "prompt-agent"); +const promptProjectDir = join(harnessDirectory, "prompt-project"); +mkdirSync(join(promptAgentDir, "prompts", "nested"), { recursive: true }); +mkdirSync(promptProjectDir, { recursive: true }); +writeFileSync( + join(promptAgentDir, "prompts", "review.md"), + "---\ndescription: Review captured changes\nargument-hint: \n---\nReview $1\n", +); +writeFileSync(join(promptAgentDir, "prompts", "empty.md"), ""); +writeFileSync( + join(promptAgentDir, "prompts", "nested", "ignored.md"), + "nested", +); +const promptTemplateDiscovery = jsonSafeJavaScriptValue( + adapters + .loadPromptTemplates({ + cwd: promptProjectDir, + agentDir: promptAgentDir, + promptPaths: [], + includeDefaults: true, + }) + .map((template) => ({ + name: template.name, + description: template.description, + argumentHint: template.argumentHint, + content: template.content, + source: template.sourceInfo?.source, + scope: template.sourceInfo?.scope, + relativeFile: + template.filePath === + join(promptAgentDir, "prompts", `${template.name}.md`) + ? `prompts/${template.name}.md` + : template.filePath, + })), +); + +// File presence, including a zero-byte file, is the native activation state +// for Pi's global instruction resources. Execute the real resource loader so +// cc-switch does not infer that rule from a parser implementation. +const instructionAgentDir = join(harnessDirectory, "instruction-agent"); +const instructionProjectDir = join(harnessDirectory, "instruction-project"); +mkdirSync(instructionAgentDir, { recursive: true }); +mkdirSync(instructionProjectDir, { recursive: true }); +for (const filename of ["AGENTS.md", "SYSTEM.md", "APPEND_SYSTEM.md"]) { + writeFileSync(join(instructionAgentDir, filename), ""); +} +const instructionLoader = new resourceAdapters.DefaultResourceLoader({ + cwd: instructionProjectDir, + agentDir: instructionAgentDir, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, +}); +await instructionLoader.reload(); +const emptyInstructionFiles = { + agentsFiles: instructionLoader.getAgentsFiles().agentsFiles.map((entry) => ({ + relativeFile: entry.path.startsWith(instructionAgentDir) + ? entry.path.slice(instructionAgentDir.length + 1) + : entry.path, + content: entry.content, + })), + systemPrompt: instructionLoader.getSystemPrompt(), + systemPromptSource: instructionLoader.getSystemPromptSource()?.path, + appendSystemPrompt: instructionLoader.getAppendSystemPrompt(), + appendSystemPromptSources: instructionLoader + .getAppendSystemPromptSources() + .map((entry) => entry.path), +}; + +// Exercise Pi's real SessionManager instead of inferring sessionDir or JSONL +// shape from its TypeScript source. Relative sessionDir is resolved against the +// launching process cwd, while the header keeps the explicit project cwd. +const sessionProjectDir = join(harnessDirectory, "session project"); +mkdirSync(sessionProjectDir, { recursive: true }); +const originalCwd = process.cwd(); +process.chdir(sessionProjectDir); +const capturedSession = adapters.SessionManager.create( + sessionProjectDir, + ".pi/sessions", + { id: "cc-switch-capture-session" }, +); +capturedSession.appendSessionInfo("Captured session"); +capturedSession.appendMessage({ + role: "user", + content: [{ type: "text", text: "captured question" }], + timestamp: 1_700_000_000_000, +}); +capturedSession.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "captured answer" }], + api: "openai-responses", + provider: "capture", + model: "capture-model", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1_700_000_001_000, +}); +const capturedSessionFile = capturedSession.getSessionFile(); +if (!capturedSessionFile) { + throw new Error("pinned SessionManager did not persist the capture session"); +} +const capturedSessionPath = resolve(capturedSessionFile); +const parsedSessionArgs = adapters.parseArgs([ + "--session", + capturedSessionPath, +]); +const parsedVersionArgs = adapters.parseArgs(["--version"]); +const sessionCliSemantics = { + argv: ["--session", capturedSessionPath], + parsedSession: parsedSessionArgs.session, + diagnostics: parsedSessionArgs.diagnostics, + versionArgv: ["--version"], + parsedVersion: parsedVersionArgs.version, + versionDiagnostics: parsedVersionArgs.diagnostics, +}; +const capturedSessionLines = capturedSessionFile + ? readFileSync(capturedSessionFile, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; +const sessionDirectorySemantics = { + processCwd: sessionProjectDir, + suppliedProjectCwd: sessionProjectDir, + suppliedSessionDir: ".pi/sessions", + resolvedSessionDir: capturedSession.getSessionDir(), + headerKeys: Object.keys(capturedSession.getHeader() ?? {}).sort(), + entryShapes: capturedSession.getEntries().map((entry) => ({ + type: entry.type, + keys: Object.keys(entry).sort(), + messageRole: entry.type === "message" ? entry.message.role : undefined, + messageKeys: + entry.type === "message" ? Object.keys(entry.message).sort() : undefined, + })), + persistedLineTypes: capturedSessionLines.map((entry) => entry.type), + listAllCount: ( + await adapters.SessionManager.listAll(capturedSession.getSessionDir()) + ).length, +}; + +const branchedSession = adapters.SessionManager.create( + sessionProjectDir, + ".pi/sessions", + { id: "cc-switch-capture-branch" }, +); +const branchRootId = branchedSession.appendMessage({ + role: "user", + content: [{ type: "text", text: "branch root" }], + timestamp: 1_700_000_002_000, +}); +branchedSession.appendSessionInfo("Abandoned branch name"); +branchedSession.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "abandoned answer" }], + api: "openai-responses", + provider: "capture", + model: "capture-model", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1_700_000_003_000, +}); +branchedSession.branch(branchRootId); +branchedSession.appendMessage({ + role: "user", + content: [{ type: "text", text: "active branch" }], + timestamp: 1_700_000_004_000, +}); +const sessionBranchSemantics = { + sessionName: branchedSession.getSessionName(), + activeEntryTypes: branchedSession.getBranch().map((entry) => entry.type), +}; + +const malformedSessionFile = join( + capturedSession.getSessionDir(), + "cc-switch-capture-malformed.jsonl", +); +writeFileSync( + malformedSessionFile, + [ + JSON.stringify(capturedSession.getHeader()), + "{not valid json", + ...capturedSession + .getEntries() + .map((entry) => JSON.stringify(entry)), + "", + ].join("\n"), +); +let malformedSessionSemantics; +try { + const malformedSession = adapters.SessionManager.open(malformedSessionFile); + malformedSessionSemantics = { + status: "accepted", + entryTypes: malformedSession.getEntries().map((entry) => entry.type), + }; +} catch (error) { + malformedSessionSemantics = { + status: "rejected", + error: error instanceof Error ? error.message : String(error), + }; +} +process.chdir(originalCwd); + +// The executed built-in tool factory is the authoritative core inventory. +// Extension-provided tools remain possible, but Pi exposes no native MCP +// registry for cc-switch to mirror as an app toggle. +const nativeToolInventory = Object.values( + adapters.createAllToolDefinitions(sessionProjectDir), +) + .map((tool) => tool.name) + .sort(); + server.close(); console.log( JSON.stringify( @@ -364,11 +754,21 @@ console.log( bundler: `esbuild@${esbuildVersion}`, piCheckout: PI, piCommit, + distributionMetadata, baseUrl, results, + minimalProviderComposition, compatSpread, compatEdgeCases, resolverCases, + skillDiscovery, + promptTemplateDiscovery, + emptyInstructionFiles, + sessionDirectorySemantics, + sessionCliSemantics, + sessionBranchSemantics, + malformedSessionSemantics, + nativeToolInventory, }, null, 2,