diff --git a/.project-docs/30-worklog/tasks/20260829-streaming-preview-b7e42a.md b/.project-docs/30-worklog/tasks/20260829-streaming-preview-b7e42a.md new file mode 100644 index 0000000..70aa2b3 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260829-streaming-preview-b7e42a.md @@ -0,0 +1,72 @@ +# Task: Keep collapsed progress following latest output + +## Identity + +- Task ID: 20260829-streaming-preview-b7e42a +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: 48a9189939f3f9a581a76e9d6f5ba2f49879a67c +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Make collapsed AI Programming thinking, process-note, and tool rows continuously show the newest non-empty progress tail while streaming. +- Add a compact whole-line upward roll for process notes and tools when streaming content advances without changing the existing one-line height; keep collapsed thinking text positionally stable. +- Split timeline typography by structured event type: assistant commentary before or between tool calls and final assistant answers use the foreground color, while thinking, tools, and operational status remain in the muted gray hierarchy. +- Add a low-contrast Codex-style text Shimmer to active collapsed progress, and stop it as soon as the enclosing run is authoritatively settled. +- Preserve complete expandable Markdown, tool input/output, details, and the existing Renderer/Main/runtime boundaries. +- Add focused Renderer and Electron E2E regression coverage and keep `README.md` synchronized. + +## Intent And Constraints + +- The collapsed state must never look frozen merely because CSS truncation keeps showing the beginning of an accumulating line. +- Collapsed text keeps natural left alignment. Short progress starts at the left edge; only overflowing content scrolls its own viewport to the newest tail. +- Long Chinese text and source/code output use a whole-line tail window rather than per-character slot rendering, which would damage joined-script typography and produce excessive animation churn. +- Streaming updates remain visible within one line; expanded content remains detailed and independently scrollable. +- Collapsed thinking may replace its text and continue the Shimmer, but it must not remount or translate the line on every streaming batch because that reads as visual jitter. +- Loading sheen is an activity signal, not decoration: it only runs for the currently active process, becomes static gray after settlement, and respects reduced-motion preferences. +- User-visible assistant commentary and final answer copy remain foreground black; thinking/tool/operational copy stays muted, errors remain destructive red, and active status indicators keep the product brand color. +- Classification must come from the existing message/thinking/tool node structure, never from Chinese or English wording heuristics. +- Do not change Host API contracts, runtime event schemas, Agent configuration, product modules, or the single light visual system. +- Preserve all pre-existing dirty-worktree changes adopted with explicit user confirmation. + +## Outcome + +- Added a shared left-aligned collapsed progress viewport. Short text begins at the left edge, while a long same-line stream automatically advances the viewport to its newest tail without right-aligning the text itself. +- Thinking and process notes now render only their latest non-empty line while collapsed, refresh it on every Snapshot, and retain complete Markdown/detail rendering when expanded. +- Tool rows use the same latest-tail behavior for current output, input, structured detail, or status while preserving full input/output and typed details in the existing expansion. +- Process-note and tool previews perform a 180 ms whole-line upward roll at bounded character intervals; reduced-motion preferences retain the repository-wide animation override. +- Collapsed thinking updates in place with a stable text baseline: it does not receive a roll revision, remount its text wrapper, or apply the vertical translate animation. Its Shimmer and newest-tail behavior continue uninterrupted. +- Active collapsed thinking, process-note, and tool previews now use muted gray text with a restrained moving Shimmer highlight; settled rows remain static muted gray. +- Expanded thinking Markdown and tool output inherit the muted hierarchy, while assistant commentary and final answer Markdown explicitly retain the foreground text color. +- Renamed the local intermediate assistant-message category to `assistant-commentary` and rendered its collapsed preview, inline code, and expanded Markdown in foreground black. Commentary updates in place without Shimmer or roll motion; adjacent thinking and tool rows retain muted styling and their activity feedback. +- Animation state is gated by the authoritative enclosing run, preventing stale historical blocks that still say `streaming` from looking active after the turn has finished. +- Inline backtick code remains visually distinct in collapsed progress without introducing per-character splitting for Chinese or source output. +- Updated focused unit/Electron E2E assertions and `README.md` to make latest-tail behavior an explicit product contract. + +## Verification + +- Exact package manager: `pnpm 10.33.4`. +- Focused timeline unit test: Pass, 1 file / 9 tests. +- `pnpm run typecheck`: Pass. +- Scoped ESLint for the changed timeline/unit/E2E files: Pass with no output. +- `pnpm run lint:check`: Pass with 0 errors; 12 pre-existing warnings remain in unrelated Home/Makelore files and a separate worktree. +- `pnpm test`: Pass, 185 regular files / 1573 passed / 3 skipped, plus the isolated pressure file 1/1. +- `pnpm run build:vite`: Pass; existing dynamic-import and chunk-size warnings remain. +- `pnpm run test:e2e -- tests/e2e/pi-coding-first-chat.spec.ts`: Pass, 2/2, including one-line height, computed left alignment, newest collapsed thinking, absence of thinking roll/translate motion, foreground assistant commentary in collapsed and expanded states, muted thinking/tools, exact long-tool-output tail scrolling, active Shimmer, and static settled tool progress. +- Development restart: Pass. Vite responds 200 at `http://localhost:5173`, the authenticated Host API responds with the expected 401 challenge at `127.0.0.1:13210`, and Electron remains running in session `77205`. + +## Follow-ups + +- None required for this behavior change. + +## Promotion Candidates + +- Target canonical document: `.project-docs/30-worklog/current-state.md`. + Proposal: after integration, record that collapsed AI Programming thinking, assistant commentary, and tool progress preserve one-line density while continuously following the newest tail and retaining complete expandable details; assistant commentary and final answers are foreground black, whereas thinking/tools/operational state are muted, with Shimmer reserved for active gray progress. + Evidence: focused streaming Snapshot regression, full unit suite, production build, and targeted Electron E2E listed above. + Future impact: future timeline refactors must not regress to start-anchored CSS truncation, visually right-aligned progress, per-character animation for long Chinese/code output, muted assistant commentary, foreground-black thinking/tool activity, loading animation on an authoritatively settled run, or vertical translate/remount jitter in collapsed thinking/commentary. + Semantic conflicts: none found; the change is Renderer-only and preserves existing runtime and Host API contracts. + Human confirmation required: no additional product decision is required beyond the user's explicit request; canonical promotion remains Integration Gate-owned. diff --git a/AGENTS.md b/AGENTS.md index 35862e0..9352d95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,13 +28,13 @@ Use the exact pnpm version pinned by `packageManager` in `package.json`. - Electron Main owns runtime startup, Host API routes, authentication, providers, secrets, synchronization, proxies, updates, and system integration. - Keep AI programming project state local and project-scoped. - Keep AI painting behind its Main-owned cloud workspace contract. The unpackaged local adapter is development-only and must never become a packaged fallback. -- Preserve internal `niancode` compatibility identifiers unless a separately approved migration covers app id, protocol, storage, environment, headers, backend contracts, and installed-user data. +- Do not change app id, protocol, global user-data paths, environment variables, request headers, or backend contracts as an incidental part of project-data work. - Keep the product on the single Makelore light visual system. Do not add a dark-theme product mode. ## Agent and project configuration -- Project configuration is authoritative in `.niancode/project.json`. -- Agent definitions are project-owned in `.niancode/project.json`; Pi runtime +- Project configuration is authoritative in `.makelore/project.json`; do not read or migrate project metadata from `.niancode` or `.opencode`. +- Agent definitions are project-owned in `.makelore/project.json`; Pi runtime state must not become an alternative source of project configuration. - Stable Agent ids preserve sessions; display names are editable. - Do not refactor Agent templates, prompts, or the Coding/Pi structure as an incidental part of unrelated work. diff --git a/README.md b/README.md index 26e85f9..c289e1b 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,19 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含四个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: -- `Makelore Code|AI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时。 +- `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 - `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、文生图、单参考图生图、视频生成任务和私有结果;参考图可从当前项目作品选择或从本地上传。Canvas 侧栏在“新建设计项目”上方提供“获取灵感”,进入服务端驱动的提示词博物馆。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 - `Makelore Learning|AI 学习`:浏览运营精选的学习项目,阅读项目 README,并把经过完整性校验的 ZIP 源码包保存到电脑继续实践。 -应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 +应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 ## 当前产品状态 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 -- AI 编程核心对话运行时:Electron Main 管理固定版本的 Pi worker、会话绑定、Provider/凭证、恢复与事件投影;Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应 worker;输入框不等待运行时准备完成。 +- AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 - 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。已写入 Pi 的 prompt 或上下文整理即使确认超过 10 秒,也会继续持有运行所有权与后台租约,直到迟到响应、权威事件、明确失败或用户清理使其收敛;此时同一 Conversation 不接受重叠 mutation,其他 Conversation 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 @@ -97,12 +97,12 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成;所有 stream、watcher、poller、loopback server 与子进程必须登记到模块活动和任务租约,不允许页面自行创建无托管后台任务。 - Works Square 原生密码与短信登录均沿 Renderer → Host API → Electron Main → Works Square 链路完成。登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复登录,连续 7 天未使用才清除会话并要求重新登录。刷新凭据始终只由 Electron Main 持有,并在正式安装包中通过系统受保护凭据存储加密落盘;可选的记住密码记录使用独立的 Main-owned 加密存储,退出登录不会清除它,只有成功的未勾选密码登录才清除旧记录。未打包开发版只在内存持有会话且禁用记住密码,避免未签名 Electron 调试进程触发 macOS 钥匙串。Renderer 现有的短效公开 access-token 会话快照与持久化保持不变(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除),账号密码不进入 Renderer 持久状态。 - AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识、非敏感作品资料和有界封面 DTO;Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。发布构建同时提供 Main-owned `ReleaseJob` 的 start/progress/status/cancel 契约,同一项目串行执行并支持取消;异步 Job 的扫描、依赖安装、构建和双归档均在独立 `utilityProcess` 中以流式文件处理,Main 只接收进度、摘要和契约,旧的同步提交接口继续兼容已有客户端。首次项目 create 使用 `/api/projects/with-cover` multipart 原子写入资料与封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH 或封面替换。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。 -- AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。 +- AI 编程项目配置只以项目内 `.makelore/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。Main 不探测、读取或迁移 `.niancode` 与 `.opencode` 项目数据。 - AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、双向命令/事件帧、断点续传与契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 - AI 学习只通过 Main-owned Host API 获取项目列表、README 详情和封面/历史媒体路径;Renderer 不持有 Works Token、对象存储地址、任意归档 URL 或本地文件路径。项目 ZIP 只允许同 Works origin 最多五跳重定向,重定向请求不携带 Bearer;下载结果仅向 Renderer 返回 `saved` 或 `cancelled`。README 不执行原始 HTML,仅 Markdown 图片节点可直接加载服务端校验后的无凭据 HTTPS URL。 - Prompt Museum 使用独立的 Main-owned Host API 代理;Renderer 只接收分页卡片、详情和服务端返回的图片地址,Works Square Token 只由 Main 持有。发布记录必须由服务端完成作者、来源、许可证和素材授权审核,模块不提供投稿、点赞、评论或排行榜。 -- AI 编程的 Agent 配置是项目所有的;稳定 id 用于保持会话兼容,显示名称可以修改。AI 绘画的设计 Agent 是固定产品能力,不作为用户可增删的项目实体。 +- AI 编程的智能体配置属于当前项目;稳定 id 用于保持对话兼容,显示名称可以修改。AI 绘画的设计 Agent 是固定产品能力,不作为用户可增删的项目实体。 ### 共享开发浏览器 @@ -112,31 +112,32 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 一键提交时,Makelore 会从待上传构建归档的同一组 Main-owned 内存字节启动临时回环站点,并在两个独立的临时 Chromium profile 中检查桌面和移动视口的主页面加载、运行错误、失败资源与白屏。临时页面不挂载到界面,不读取或写入用户浏览器的 Cookie、历史和登录态;检查结束后始终销毁并清理,也不要求用户预先打开开发预览。 - 客户端复用 Electron 内置 Chromium,不安装 Playwright 或额外浏览器。预检只改善提交前反馈,可被非官方客户端绕过,也不会上传“已通过”凭据;平台仍把源码、构建归档和清单视为不可信输入,逐字节重算并在人工审核后发布。安装包携带固定 npm 运行时,项目依赖和 Vite 版本由 `package-lock.json` 锁定;依赖准备需要本地网络。 -### 项目内置编码 Skills +### 项目内置编码技能 -- 项目随产品提供 `agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划)四个编码 Skill。它们从 vendor-neutral 的 `resources/coding-skills/` 打包,由 Electron Main 按 Agent 选择直接加载;未选择的 Skill 不进入该 Agent 的 Pi 资源集合。 -- 创建项目伙伴时,`agent-browser`、`grilling` 与 `planning-with-files` 默认勾选;`frontend-slides` 作为专项能力可手动选择。用户可以在创建或维护伙伴时调整选择。最终选择写入项目 Agent 的 `skillIds`,未选择的 Skill 保持拒绝权限。 -- `grilling` 会在复杂实现前逐项确认高影响决策,用户确认前不执行变更。`planning-with-files` 只在复杂、可分阶段或需要跨会话恢复的任务中使用,并把 `task_plan.md`、`findings.md` 和 `progress.md` 直接保存到当前项目根目录,不写入 Skill 安装目录、用户目录或 `.niancode/agent-planning/`。 +- 项目随产品提供 `agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划)四项编码技能。它们从 vendor-neutral 的 `resources/coding-skills/` 打包,由 Electron Main 按智能体配置直接加载;未启用的技能不进入该智能体的 Pi 资源集合。 +- 创建项目智能体时,`agent-browser`、`grilling` 与 `planning-with-files` 默认启用;`frontend-slides` 作为专项能力可手动启用。用户可以在创建或编辑智能体时调整选择。最终选择写入项目智能体的 `skillIds`,未启用的技能保持拒绝权限。 +- `grilling` 会在复杂实现前逐项确认高影响决策,用户确认前不执行变更。`planning-with-files` 只在复杂、可分阶段或需要跨会话恢复的任务中使用,并把 `task_plan.md`、`findings.md` 和 `progress.md` 直接保存到当前项目根目录,不写入 Skill 安装目录、用户目录或隐藏配置目录。 - `frontend-slides` 只在用户准备项目展示、汇报或结题时自动调用,生成项目目录中的固定 16:9 HTML 演示和相对路径素材;它不生成 `.pptx`,不访问云部署服务。 -### 项目伙伴与会话 +### 项目智能体与对话 -- 新项目默认没有伙伴或 Agent;用户在项目内手动创建伙伴时必须填写名称、预设头像或本地头像、职责和精确的 Provider 账号与模型。该选择以 `{ accountId, modelId, thinkingLevel }` 保存,不依赖运行时私有 Provider id。本地头像会自动裁剪为 256×256,并优先压缩为 WebP 后随项目配置保存;提示词与 Skill 属于后置高级设置。 -- 项目配置页的新增与已有伙伴维护统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。模型资源抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。伙伴模型只作为新 Conversation 的默认值;核心聊天页可为当前 Conversation 独立切换模型和 thinking,切换不会改写伙伴默认值或其他 Conversation。 -- 读取旧项目时会把仍缺少模型的伙伴从兼容保留的 `defaultModel` 自动迁移到伙伴自身配置,之后运行时只认伙伴模型。 -- 技能资源入口使用扳手图标;点击已安装 Skill 后先展示其目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。 -- 一个伙伴可以拥有多条互相独立的 Pi Session;伙伴和 Conversation 元数据分别由项目配置与 `.niancode/conversations.json` 保存,稳定 Agent/Conversation id 保持本地历史连续。 -- 核心聊天页左侧把本地 Conversation 嵌套在展开的所属伙伴下,并在该伙伴子组中提供新建入口;首次选择没有 Conversation 的伙伴时立即创建本地元数据,同时异步准备对应运行时。即使准备被阻塞或超时,输入框仍可编辑,草稿也不会丢失。 +- 新项目默认没有智能体;用户在项目内创建智能体时必须填写名称、预设头像或本地头像、职责说明和精确的 Provider 账号与默认模型。该选择以 `{ accountId, modelId, thinkingLevel }` 保存,不依赖运行时私有 Provider id。本地头像会自动裁剪为 256×256,并优先压缩为 WebP 后随项目配置保存;系统指令与技能属于高级设置。 +- Makelore 为每个项目智能体附加平台管理的中文语言规则:自然语言默认使用简体中文,包括可见的思考过程、进度说明、工具调用说明和最终回复;代码、命令、路径、日志、错误原文与必要引用保留原始语言。用户或项目智能体系统指令明确要求其他语言时按该要求执行;模型仍不遵循时视为 Provider 模型行为。 +- 项目配置页的新增与已有智能体编辑统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。“可用模型”抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。智能体的默认模型只作为新对话的初始值;核心聊天页可为当前对话独立切换模型和思考强度,切换不会改写智能体默认值或其他对话。 +- 项目智能体只保存自身的精确默认模型;未完成模型选择的智能体保持待配置状态,不从项目级 `defaultModel` 或运行时私有配置回退。 +- “可用技能”入口使用模块图标;点击技能后先展示目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。 +- 一个智能体可以拥有多条互相独立的 Pi Session;智能体和对话元数据分别由 `.makelore/project.json` 与 `.makelore/conversations.json` 保存,稳定的智能体与对话 id 保持本地历史连续。 +- 核心聊天页左侧把本地对话嵌套在展开的所属智能体下,并在该智能体子组中提供新建入口;首次选择没有对话的智能体时立即创建本地元数据,同时异步准备对应运行时。即使准备被阻塞或超时,输入框仍可编辑,草稿也不会丢失。 - Conversation 历史按需从 Main-owned Snapshot 读取。公开 SSE 只交付 Snapshot 与按 Conversation、worker generation 分组的 `patch-batch`;Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。 -- 核心时间线渲染消息、Markdown、thinking、工具、压缩、轮次边界、通知和 `subagent.v1` 单个/并行/串行子任务;默认保留最近 120 个节点的渲染窗口,可按 100 个节点加载更早内容。工具结果和浏览器附件保留在对应工具卡片内,累计输出覆盖同一块而不形成独立气泡;压缩只展示产品摘要、重试和结算状态。 -- Composer 支持文字、粘贴或选择 PNG/JPEG/WebP/GIF 图片,每条消息最多 16 张、最多并行上传 4 张。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;Main 在落盘前核对 MIME 与最小图片签名,状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。 -- Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。最终产品中的 Pi 运行时会通过受控 Provider-shaped 回环 smoke 验证两个 worker 的重叠执行、状态隔离与凭证引用隔离;真实外部 Provider 的并发、限流、协议兼容和凭证隔离仍是独立风险,未执行真实 Provider 验证时不得标记为 Pass。 +- 核心时间线按每条用户消息组织为“用户消息、连续处理过程、结论回复”。Pi 的流式 thinking、工具、压缩、重试、通知和 `subagent.v1` 单个/并行/串行子任务共享一条轻量过程轨道;运行时显示“处理中”和活动指示,完成后按耗时(历史缺少计时时按项目数)收纳为可展开的“已处理”摘要。文字层级按结构化事件类型划分,不按文案关键词猜测:助手在工具调用前后直接对用户说的过程正文与最终结论都使用黑色;thinking、工具输入输出、压缩、子任务和运行状态使用浅灰辅助文字,仍在运行的灰色缩略文字带有低对比度 Shimmer 擦拭高光,结束后停止动画;错误状态保持红色。流式 thinking 与助手过程正文默认用一行高度展示最新内容,缩略态保持自然左对齐并始终追随最后一条非空内容:短内容从左侧开始,超出可视宽度时才在行内滚到最新尾部;两类文字都在原位更新且不做位移动画,助手过程正文也不使用 Shimmer。工具调用用一行摘要持续展示工具名称、最新输入或输出和运行状态,并可按流式批次轻量整行轮动。思考、助手过程正文和工具节点均可展开查看完整详情,工具的累计输出和浏览器附件留在对应的二级展开区而不形成独立气泡。结论回复按安全 Markdown 排版标题、列表、引用、代码和表格;可点击的 HTTP/HTTPS 与本地 HTML 链接统一使用无底色的橙色文字。左键通过 Main-owned 系统入口交给默认浏览器打开;右键可打开系统菜单,网页链接可复制地址,本地链接还可在访达或系统文件夹中显示。同一回复已经给出完整 HTML 路径时,对应的相对查看路径也会解析到该文件。内部 `turn-start`/`turn-end` 只维持事件顺序、不呈现在界面。时间线默认保留最近 120 个节点的渲染窗口,可按 100 个节点加载更早内容。 +- Composer 使用贴底的复合输入面板,支持文字、登录后的语音转写,以及粘贴或选择 PNG/JPEG/WebP/GIF 图片;每条消息最多 16 张图片、最多并行上传 4 张。语音录制通过现有 Main-owned Works Speech Host API 转成文字,并在项目或账号切换时立即停止;当前对话的模型与思考强度组合入口、语音及发送/中止入口保留在底部控制行,模型入口默认使用透明底色。模型与思考强度通过向上展开的分级菜单切换,只列出当前 Provider 或 Makelore 已验证模型档案实际支持的选项;Qwen3.6 Plus 使用混合思考开关,Qwen3.8 Max 暴露低/中/高三档并把产品“高”映射到服务端 `xhigh`。模型仅报告 `off` 时明确显示“不可调”,不伪造未知模型的推理档位。切换完成后静默刷新当前 Snapshot,只有真正恢复 worker 时才显示本地 Agent 重连提示。重复读取 Snapshot 不发布无变化的 worker 状态;Snapshot 与 SSE 批次竞态时,Renderer 会丢弃已覆盖事件并只应用连续的新尾部,避免形成伪重连循环。上下文接近模型限制或发生溢出时由 Pi 自动整理,Composer 不提供手动整理入口;自动整理的进度和结果仍显示在对话过程轨道中。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;Main 在落盘前核对 MIME 与最小图片签名,状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。智能体报告 `changed-file.v1` 后,输入框上方会展示当前轮次的文件数与增删行统计,并可展开查看 Main-owned 变更快照中的逐文件差异。 +- Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。最终产品中的 Pi 运行时会通过受控 Provider-shaped 回环 smoke 验证同一 Agent Server 内多个 Conversation 逻辑线程的重叠执行、状态隔离、凭证引用隔离、单线程关闭和 Server 崩溃后重启,以及独立子 Agent 进程的隔离与回收;真实外部 Provider 的并发、限流、协议兼容和凭证隔离仍是独立风险,未执行真实 Provider 验证时不得标记为 Pass。 - 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。 -- 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列只在 `agent_settled` 后释放,用户可中止当前运行。select/confirm/input/editor 交互在输入区上方回答,并明确展示取消或失效结果。 -- 核心聊天页支持标题、归档、未读、恢复,以及从已持久化的 user 消息“从这里创建新对话分支”;assistant 消息和未持久化消息不提供该动作。分支只创建新的 Conversation 历史,不表示文件回滚。右侧编程工具集中展示当前 Conversation 的 changes、项目文件预览、浏览器附件、技能、命令与脱敏运行诊断。分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 +- 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列只在 `agent_settled` 后释放,用户可中止当前运行。select/confirm/input/editor 交互在输入区上方回答,select 除预设选项外支持自由输入;交互提交或取消后立即从输入区移除,失效请求会就地提示并允许重试或取消。 +- 核心聊天页标题栏左侧只显示 Conversation 名称,必要的中止/恢复入口保留在右侧;不再展示 Pi 空闲/运行副标题。对话列表栏只显示项目名称和项目设置入口,不显示“Pi 本地对话”副标题;全局品牌 Logo 贴齐窗口最右边缘。归档、未读、整段 Conversation 分支和编程工具抽屉均不占用标题栏。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 -## 兼容标识 +## 运行内核与项目数据 -Makelore 2.0 继续保留既有 `niancode` 包名、应用 id、协议、数据目录、环境变量、API 请求头和服务标识,以兼容现有安装与服务。它们是内部技术契约,不是对外品牌名称。 +Pi 是 Makelore Code 唯一的智能体运行内核;产品不携带或回退到 OpenCode 运行时。项目元数据统一写入 `.makelore`,没有 `.niancode` / `.opencode` 双读、旧项目自动迁移、旧对话提示状态或对应 Host API。应用 id、桌面协议、全局用户数据目录、环境变量、API 请求头和服务标识属于独立的安装与服务端集成契约,不作为项目数据路径,也不在用户界面中作为产品术语展示;修改这些契约需要单独协调桌面发布与服务端切换。 本仓库只描述当前产品状态,不保存旧产品的任务记录、设计过程、工作日志或迁移历史。 diff --git a/docs/pi-runtime-release-runbook.md b/docs/pi-runtime-release-runbook.md index 8dfbd9f..b1dca82 100644 --- a/docs/pi-runtime-release-runbook.md +++ b/docs/pi-runtime-release-runbook.md @@ -4,16 +4,14 @@ ## 发布边界 -- 正式包固定使用仓库锁定的 Pi 版本和生产依赖闭包。Electron Main 从安装目录中的 `resources/pi-runtime` 解析运行时、扩展、Manifest 与编码 Skills;缺失、版本漂移、开发路径泄漏或产品自有的 OpenCode runtime/资源残留均阻断发布。锁定的 `@earendil-works/pi-ai` 生产包会静态导入其内置 `providers/opencode*` 模块;这些上游文件必须在报告中单独列出,不能冒充旧 Makelore OpenCode runtime,也不能为制造“零命中”而破坏 Pi 闭包。 +- 正式包固定使用仓库锁定的 Pi 版本和生产依赖闭包。Electron Main 从安装目录中的 `resources/pi-runtime` 解析运行时、扩展、Manifest 与编码 Skills,并从 `resources/pi-agent-server.mjs` 启动共享 Agent Server;任一资源缺失、版本漂移、开发路径泄漏或产品自有的 OpenCode runtime/资源残留均阻断发布。锁定的 `@earendil-works/pi-ai` 生产包会静态导入其内置 `providers/opencode*` 模块;这些上游文件必须在报告中单独列出,不能冒充旧 Makelore OpenCode runtime,也不能为制造“零命中”而破坏 Pi 闭包。 - Renderer 只使用 Main-owned Host API 和产品 Conversation 契约,不读取 Pi wire 类型,也不直接启动 worker、访问本地运行时地址或接触 Provider 凭证。 -- 产品保留既有 `niancode` 应用 id、协议、数据目录和项目配置兼容标识;这不代表旧 OpenCode 运行时、路由或资源仍被携带。 +- 项目配置和 Conversation 元数据只使用 `.makelore`;不得携带 `.niancode` / `.opencode` 项目探测、迁移、兼容路由或提示状态。应用 id、桌面协议、全局用户数据路径与服务端请求头是独立发布契约,不属于项目数据格式。 - 正式产品界面不提供 share/unshare、revert/unrevert、todos 或全局运行时控制。项目分支只创建新的 Conversation 历史,不表示文件回滚。 -## Conversation 兼容性 +## Conversation 数据边界 -升级前已有的 OpenCode Conversation 数据继续保留,项目首次进入 Pi 会显示一次迁移说明,但 Pi 不导入、续写或改写这些旧 Conversation。升级后新建的 Pi Conversation 使用新的本地契约;旧版本不能读取或续写它们。 - -因此,旧 Conversation 的“保留”是可回滚的数据边界,不是跨运行时续写兼容。不得通过复制 Session、混用资源目录或在同一安装中切换旧/新 runtime 来绕过该边界。 +产品只支持 Pi Conversation。项目元数据分别写入 `.makelore/project.json` 与 `.makelore/conversations.json`,Pi Session 由 Main-owned registry 绑定;运行时不探测、导入、续写或改写 OpenCode Conversation,也不提供旧格式提示与确认接口。不得通过复制 Session、混用资源目录或在同一安装中切换不同 runtime 来绕过该边界。 ## 必须验证的正式产物 @@ -36,7 +34,7 @@ pnpm run smoke:pi:real -- --app-exe --samples 5 --rep pnpm run perf:pi:release -- --app-exe --samples 5 --report ``` -产物验证必须确认版本与 Node engine、生产依赖闭包、扩展、Manifest、编码 Skills、`resolve/get_state`、无产品自有 OpenCode runtime/资源、上游 Pi provider 例外清单,以及无构建工作区绝对路径。Smoke 必须从最终产品可执行文件启动最终 `resources/pi-runtime`,覆盖 session、prompt、tool、abort、settle、reopen、双 worker 重叠与隔离、子 Agent 和 shutdown。性能报告必须记录 p50/p95/max/样本数组、RSS、Main→Renderer IPC/提交延迟和 Git commit,并覆盖 Spec 17.3 的十个场景。 +产物验证必须确认版本与 Node engine、生产依赖闭包、共享 Agent Server、扩展、Manifest、编码 Skills、`resolve/get_state`、无产品自有 OpenCode runtime/资源、上游 Pi provider 例外清单,以及无构建工作区绝对路径。Smoke 必须从最终产品可执行文件启动最终 `resources/pi-agent-server.mjs` 与 `resources/pi-runtime`,覆盖 session、prompt、tool、abort、settle、reopen、同一 Server 内多线程重叠与隔离、单线程关闭、Server 崩溃重启、独立子 Agent 进程和 shutdown;四条父 Conversation 并发时应只有一个父 Agent Server 进程。性能报告必须记录 p50/p95/max/样本数组、RSS、Main→Renderer IPC/提交延迟和 Git commit,并覆盖 Spec 17.3 的十个场景。 ## Provider 验证的准确含义 @@ -55,12 +53,12 @@ pnpm run perf:pi:release -- --app-exe --samples 5 --r ## 整版本回滚 -Pi 与旧运行时之间只支持完整应用版本回滚,不支持运行时组件级回滚或混装: +Pi 版本之间只支持完整应用版本回滚,不支持运行时组件级回滚或混装: -1. 停止发布和自动更新,完整备份目标用户数据目录及项目中的 `.niancode` 数据。 -2. 退出 Makelore,确认没有 Pi worker 或安装器进程仍在运行。 -3. 安装上一完整、已验证的应用版本;不得把旧 runtime 覆盖到新版本 `resources`,也不得保留新旧文件拼接后的安装目录。 -4. 旧版本只继续使用其原本可读的旧 Conversation。升级后新建的 Pi Conversation 保留在磁盘中,但对旧版本不兼容,不能读取或续写。 -5. 需要重新升级时,安装完整 Pi 版本并重新执行该版本的项目迁移与一次性说明流程;不要在不同版本间复制 Session 文件。 +1. 停止发布和自动更新,完整备份目标用户数据目录及项目中的 `.makelore` 数据。 +2. 退出 Makelore,确认没有 Pi Agent Server、子 Agent 或安装器进程仍在运行。 +3. 安装上一完整、已验证且支持当前 `.makelore` schema 的应用版本;不得把单独的 runtime 覆盖到 `resources`,也不得保留不同版本文件拼接后的安装目录。 +4. 回滚后验证项目配置、Conversation 元数据和 Pi Session 绑定均可正常读取;不要在版本间手工复制或编辑 Session 文件。 +5. 需要重新升级时安装完整 Pi 版本,并重新执行该版本的正式产物验证与 smoke。 -回滚完成后,应验证应用标识与用户数据目录未改变、旧 Conversation 仍可按旧版本能力读取、Pi Conversation 字节未被旧版本改写。任何需要手工编辑 Conversation 文件才能恢复的情况都应停止操作并保留备份。 +回滚完成后,应验证应用标识与全局用户数据目录未改变、`.makelore` 项目数据未被意外改写。任何需要手工编辑项目或 Conversation 文件才能恢复的情况都应停止操作并保留备份。 diff --git a/electron-builder.yml b/electron-builder.yml index 013ddfe..dd5be5f 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -11,6 +11,7 @@ directories: files: - dist - dist-electron + - "!dist-electron/**/*.tsbuildinfo" - package.json beforePack: ./scripts/before-pack.cjs diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index e6d4fd0..98c8f05 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -1,3 +1,4 @@ +import { accessSync, constants } from 'node:fs'; import path from 'node:path'; import type { AgentBrowserModule } from '../agent-browser'; import { CodingAttachmentStore } from '../coding-projects/attachment-store'; @@ -10,6 +11,7 @@ import { } from '../coding-projects/project-store'; import { CodingConversationService } from '../coding-runtime/conversation-service'; import { PiManagedExtensionHost } from '../coding-runtime/pi/extension-host'; +import { PiAgentServerProcess } from '../coding-runtime/pi/agent-server-process'; import { PiManagedInputRevisionCoordinator } from '../coding-runtime/pi/managed-input-revision'; import { PiProductTools } from '../coding-runtime/pi/product-tools'; import { @@ -31,12 +33,15 @@ import { refreshCodingProviderCredential, } from './coding-provider-auth'; import { createCodingProductHost, type CodingProductComposition } from './coding-product-services'; -import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader'; -import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1'; +import { + archivePiConversationSession, + getPiManagedPaths, +} from '../coding-runtime/pi/resource-loader'; export interface CodingCompositionPaths { executablePath: string; cliPath: string; + serverPath: string; userDataDir: string; bundledSkillsDir: string; } @@ -50,14 +55,51 @@ export interface CreateCodingCompositionOptions { acquireBackgroundLease?(lease: { id: string; kind: 'coding-run' }): () => void; } +type PiWorkerExecutableProbe = (candidate: string) => boolean; + +function canExecute(candidate: string): boolean { + try { + accessSync(candidate, constants.X_OK); + return true; + } catch { + return false; + } +} + +export function resolvePiWorkerExecutablePath( + executablePath: string, + options: { + platform?: NodeJS.Platform; + canExecute?: PiWorkerExecutableProbe; + } = {}, +): string { + if ((options.platform ?? process.platform) !== 'darwin') return executablePath; + + // The product binary remains a Foreground LaunchServices app even in Node mode. + // Electron's generic Helper is LSUIElement=true, so workers stay out of the Dock. + const executableName = path.basename(executablePath); + const helperExecutablePath = path.resolve( + path.dirname(executablePath), + '..', + 'Frameworks', + `${executableName} Helper.app`, + 'Contents', + 'MacOS', + `${executableName} Helper`, + ); + return (options.canExecute ?? canExecute)(helperExecutablePath) + ? helperExecutablePath + : executablePath; +} + export function resolveCodingPiRuntimePaths(input: { isPackaged: boolean; resourcesPath: string; appPath: string; executablePath: string; -}): Pick { +}): Pick { return { - executablePath: input.executablePath, + executablePath: resolvePiWorkerExecutablePath(input.executablePath), cliPath: input.isPackaged ? path.join(input.resourcesPath, 'pi-runtime', 'dist', 'cli.js') : path.join( @@ -68,6 +110,9 @@ export function resolveCodingPiRuntimePaths(input: { 'dist', 'cli.js', ), + serverPath: input.isPackaged + ? path.join(input.resourcesPath, 'resources', 'pi-agent-server.mjs') + : path.join(input.appPath, 'resources', 'pi-agent-server.mjs'), }; } @@ -100,12 +145,20 @@ export function createCodingComposition( }); const revisions = new PiManagedInputRevisionCoordinator(); const processBudget = new PiProcessBudget(); + const agentServer = new PiAgentServerProcess({ + executablePath: options.paths.executablePath, + serverPath: options.paths.serverPath, + runtimeRoot: path.dirname(path.dirname(options.paths.cliPath)), + configDir: getPiManagedPaths(options.paths.userDataDir).configDir, + }); const loadProviderInput = async () => ({ accounts: await getProviderService().listAccounts(), modelSummaries: [], }); const workerPool = new PiWorkerPool({ processBudget, + processMode: 'shared', + maxIdle: 8, revisionCoordinator: revisions, openWorker: createPiManagedWorkerOpener({ registry, @@ -115,6 +168,7 @@ export function createCodingComposition( bundledSkillsDir: options.paths.bundledSkillsDir, loadProviderInput, resolveCredential: resolvePiProviderCredentialFromSecretStore, + createProcess: (processOptions) => agentServer.createWorker(processOptions), ...(getLocalProxyCredential ? { getLocalProxyCredential: async () => getLocalProxyCredential() } : {}), @@ -138,7 +192,6 @@ export function createCodingComposition( const subagents = new PiSubagentScheduler({ openChild: childOpener, processBudget, - reclaimProcessCapacity: (signal) => workerPool.reclaimIdleWorker(signal), }); const runtime = new PiConversationRuntime({ pool: workerPool, @@ -164,12 +217,6 @@ export function createCodingComposition( : {}), }); const projects = new CodingProjectService(projectStore, { - migration: { - resolveLegacyModel: async ({ legacyModel }) => resolveLegacyProjectModel( - legacyModel, - await getProviderService().listAccounts(), - ), - }, createConversationStore: conversationStoreForProject, onResourcesChanged: async (project) => { runtime.markResourcesStale(); @@ -215,10 +262,15 @@ export function createCodingComposition( await Promise.allSettled(conversationIds.map((conversationId) => ( runtime.dispose(conversationId, reason) ))); + await agentServer.stop(); }, async shutdown() { await subagents.close(); - await runtime.shutdown(); + try { + await runtime.shutdown(); + } finally { + await agentServer.stop(); + } }, }; } diff --git a/electron/api/routes/auth.ts b/electron/api/routes/auth.ts index 2d57ca7..e6d549e 100644 --- a/electron/api/routes/auth.ts +++ b/electron/api/routes/auth.ts @@ -63,6 +63,10 @@ type SessionRefreshInput = { const MAX_AUTH_ERROR_LENGTH = 180; const MAX_CAPTCHA_IMAGE_BYTES = 1024 * 1024; +const MAX_PUBLIC_USERNAME_LENGTH = 256; +const MAX_PUBLIC_ID_LENGTH = 128; +const MAX_PUBLIC_AUTHORITIES = 100; +const MAX_PUBLIC_AUTHORITY_LENGTH = 128; const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; @@ -90,6 +94,57 @@ function readOptionalBoolean(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback; } +function readBoundedString(value: unknown, maxLength: number): string | null { + const normalized = readOptionalTrimmedString(value); + return normalized && normalized.length <= maxLength ? normalized : null; +} + +function readPublicStringId(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + const normalized = readBoundedString(value, MAX_PUBLIC_ID_LENGTH); + if (normalized) return normalized; + } + return null; +} + +function readPublicStringOrNumberId(...values: unknown[]): string | number | null { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + const normalized = readBoundedString(value, MAX_PUBLIC_ID_LENGTH); + if (normalized) return normalized; + } + return null; +} + +function readPublicAuthorities(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((authority) => readBoundedString(authority, MAX_PUBLIC_AUTHORITY_LENGTH)) + .filter((authority): authority is string => authority !== null) + .slice(0, MAX_PUBLIC_AUTHORITIES); +} + +function projectCurrentUser(profile: Record): { + username: string; + userId: string | null; + tenantId: string | number | null; + deptId: string | number | null; + authorities: string[]; +} | null { + const username = readBoundedString(profile.username, MAX_PUBLIC_USERNAME_LENGTH) + ?? readBoundedString(profile.user_name, MAX_PUBLIC_USERNAME_LENGTH); + if (!username) return null; + + return { + username, + userId: readPublicStringId(profile.user_id, profile.userId, profile.id), + tenantId: readPublicStringOrNumberId(profile.tenant_id, profile.tenantId), + deptId: readPublicStringOrNumberId(profile.dept_id, profile.deptId), + authorities: readPublicAuthorities(profile.authorities), + }; +} + function withoutRefreshToken(payload: unknown): unknown { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload; const { refresh_token: _refreshToken, ...publicPayload } = payload as Record; @@ -600,6 +655,7 @@ async function handleCurrentUser(res: ServerResponse, ctx: HostApiContext): Prom : {}; sendJson(res, 200, { success: true, + user: projectCurrentUser(profile), moduleAccess: normalizeModuleAccess(profile.module_access), }); } diff --git a/electron/api/routes/coding-projects.ts b/electron/api/routes/coding-projects.ts index b836ee9..f94a954 100644 --- a/electron/api/routes/coding-projects.ts +++ b/electron/api/routes/coding-projects.ts @@ -114,16 +114,6 @@ export async function handleCodingProjectRoutes( }); return true; } - if (url.pathname === '/api/coding/projects/legacy-conversation-notice/acknowledge' - && req.method === 'POST') { - const body = await parseJsonBody<{ projectId?: string }>(req); - sendJson(res, 200, { - snapshot: publicProjectSnapshot( - await projects.acknowledgeLegacyConversationNotice(body.projectId ?? ''), - ), - }); - return true; - } if (url.pathname === '/api/coding/projects/conversations' && req.method === 'GET') { sendJson(res, 200, { conversations: await conversations.listConversations( diff --git a/electron/coding-projects/conversation-store.ts b/electron/coding-projects/conversation-store.ts index 633494a..806c8e9 100644 --- a/electron/coding-projects/conversation-store.ts +++ b/electron/coding-projects/conversation-store.ts @@ -5,7 +5,7 @@ import type { ConversationModelState, ProductModelRef } from '../coding-runtime/ import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json'; import { normalizeProductModelRef } from './project-config'; -export const CODING_CONVERSATIONS_PATH = '.niancode/conversations.json'; +export const CODING_CONVERSATIONS_PATH = '.makelore/conversations.json'; export interface CodingConversationV2 extends CodingConversationMetadata, ConversationModelState { piSessionId?: string; diff --git a/electron/coding-projects/game-asset-review.ts b/electron/coding-projects/game-asset-review.ts index b81e5f5..e3140af 100644 --- a/electron/coding-projects/game-asset-review.ts +++ b/electron/coding-projects/game-asset-review.ts @@ -56,7 +56,7 @@ export class GameAssetReviewConflictError extends Error { const stateWriteQueues = new Map>(); function assetReviewStatePath(projectPath: string): string { - return join(projectPath, '.niancode', STATE_FILE_NAME); + return join(projectPath, '.makelore', STATE_FILE_NAME); } function now(): string { @@ -136,7 +136,7 @@ async function readState(projectPath: string): Promise { } async function writeState(projectPath: string, state: GameAssetReviewState): Promise { - const directory = join(projectPath, '.niancode'); + const directory = join(projectPath, '.makelore'); await mkdir(directory, { recursive: true }); const target = assetReviewStatePath(projectPath); const temporary = `${target}.${randomUUID()}.tmp`; diff --git a/electron/coding-projects/legacy-v1.ts b/electron/coding-projects/legacy-v1.ts deleted file mode 100644 index 2cca9cf..0000000 --- a/electron/coding-projects/legacy-v1.ts +++ /dev/null @@ -1,267 +0,0 @@ -import path from 'node:path'; -import { - isProjectAgentAvatarDataUrl, - isProjectType, - validateAgentNames, - type ProjectAgentConfig, - type ProjectConfig, - type ProjectType, -} from '../../shared/project-config'; -import type { ProductModelRef } from '../coding-runtime/contracts'; -import type { ProviderAccount } from '../shared/providers/types'; - -const RETIRED_PROJECT_SKILL_IDS = new Set(['game-engine']); - -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return [...new Set(value - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter(Boolean))]; -} - -function normalizeProjectSkillIds(value: unknown): string[] { - return normalizeStringList(value).filter((skillId) => !RETIRED_PROJECT_SKILL_IDS.has(skillId)); -} - -function normalizeProjectType(value: unknown): ProjectType { - if (value === undefined) return 'custom'; - if (isProjectType(value)) return value; - throw new Error('Invalid project type'); -} - -function normalizeAgent(value: unknown): ProjectAgentConfig | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const raw = value as Partial; - const id = typeof raw.id === 'string' ? raw.id.trim() : ''; - const name = typeof raw.name === 'string' ? raw.name.trim() : ''; - if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) return null; - const rawResponsibility = raw.responsibility; - const responsibility = rawResponsibility && typeof rawResponsibility === 'object' - ? rawResponsibility as Partial - : {}; - return { - id, - avatarId: typeof raw.avatarId === 'string' && /^avatar-(0[1-9]|1[0-6])$/.test(raw.avatarId) - ? raw.avatarId - : 'avatar-01', - avatarDataUrl: isProjectAgentAvatarDataUrl(raw.avatarDataUrl) ? raw.avatarDataUrl : undefined, - roleName: typeof raw.roleName === 'string' && raw.roleName.trim() - ? raw.roleName.trim() - : '项目伙伴', - name, - builtIn: raw.builtIn === true, - enabled: raw.enabled !== false, - model: typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : null, - skillIds: normalizeProjectSkillIds(raw.skillIds), - responsibility: { - mission: typeof responsibility.mission === 'string' ? responsibility.mission.trim() : '', - owns: normalizeStringList(responsibility.owns), - boundaries: normalizeStringList(responsibility.boundaries), - collaborators: normalizeStringList(responsibility.collaborators), - principles: normalizeStringList(responsibility.principles), - }, - prompt: typeof raw.prompt === 'string' ? raw.prompt.trim() : '', - archivedAt: typeof raw.archivedAt === 'string' && raw.archivedAt.trim() - ? raw.archivedAt.trim() - : null, - pinned: raw.pinned === true, - createdAt: typeof raw.createdAt === 'string' && raw.createdAt.trim() - ? raw.createdAt.trim() - : undefined, - updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt.trim() - ? raw.updatedAt.trim() - : undefined, - }; -} - -export function normalizeLegacyProjectConfigV1(value: unknown): ProjectConfig { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Project config must be an object'); - } - const raw = value as Partial; - if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema'); - const defaultModel = typeof raw.defaultModel === 'string' && raw.defaultModel.trim() - ? raw.defaultModel.trim() - : null; - const agents = Array.isArray(raw.agents) ? raw.agents.map(normalizeAgent) : []; - if (agents.some((agent) => !agent)) throw new Error('Invalid project Agent configuration'); - const normalizedAgents = agents - .filter((agent): agent is ProjectAgentConfig => Boolean(agent)) - .map((agent) => agent.model || !defaultModel ? agent : { ...agent, model: defaultModel }); - if (new Set(normalizedAgents.map((agent) => agent.id)).size !== normalizedAgents.length) { - throw new Error('Duplicate project Agent id'); - } - const createdAt = typeof raw.createdAt === 'string' && raw.createdAt - ? raw.createdAt - : new Date().toISOString(); - const initialized = raw.initialized === true; - if (initialized && validateAgentNames(normalizedAgents).length > 0) { - throw new Error('Initialized project has invalid Agent names'); - } - return { - schemaVersion: 1, - projectType: normalizeProjectType(raw.projectType), - initialized, - defaultModel, - agents: normalizedAgents, - knowledgeDirectory: 'knowledge', - createdAt, - updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt ? raw.updatedAt : createdAt, - }; -} - -function yamlString(value: string): string { - return JSON.stringify(value); -} - -function buildProjectAgentPrompt(config: ProjectConfig, current: ProjectAgentConfig): string { - const peers = config.agents.filter((agent) => agent.id !== current.id); - const responsibility = current.responsibility.mission.trim() - ? [ - '## 你的职责', - current.responsibility.mission, - '', - '## 负责内容', - ...current.responsibility.owns.map((item) => `- ${item}`), - '', - '## 工作边界', - ...current.responsibility.boundaries.map((item) => `- ${item}`), - '', - '## 工作原则', - ...current.responsibility.principles.map((item) => `- ${item}`), - '', - ] - : []; - return [ - `# ${current.name} · ${current.roleName}`, - '', - `你的名字是「${current.name}」,在与用户对话和介绍自己时使用这个名字。`, - `你在当前项目中的职能是「${current.roleName}」。`, - `你的稳定角色 ID 是 \`${current.id}\`。你只属于当前项目。`, - '', - current.prompt.trim(), - ...responsibility, - '请尊重用户已有文件和未提交改动;遇到不确定的高影响决策时先说明影响。', - '', - '## 项目伙伴', - ...(peers.length > 0 - ? peers.map((peer) => `- ${peer.name}(${peer.roleName},${peer.id})`) - : ['- 暂无其他项目伙伴。']), - '', - '项目目录是当前项目的长期上下文。开始工作前读取与任务相关的文件,结束时把实际进展、假设、证据、风险和下一步写入项目文件。', - ].join('\n'); -} - -function buildSelectedSkillGuidance(skillIds: string[]): string { - const guidance: string[] = []; - if (skillIds.includes('frontend-slides')) { - guidance.push([ - '## 自动调用:项目演示', - '当用户要求项目汇报、结题展示、答辩、项目介绍或其他演示文稿时,主动调用 `frontend-slides`,把当前项目的真实内容整理成可播放的本地 HTML 幻灯片。项目接近完成且交付清单需要项目演示时,主动提醒用户并准备生成;不要生成 PPTX、不要部署到云端。', - ].join('\n')); - } - if (skillIds.includes('grilling')) { - guidance.push([ - '## 自动调用:方案质询', - '当任务涉及重要实现、设计、架构或其他未确认的高影响决策时,主动调用 `grilling`。在用户确认共享理解前,不要修改项目状态。', - ].join('\n')); - } - if (skillIds.includes('planning-with-files')) { - guidance.push([ - '## 自动调用:项目规划', - '当任务包含多个阶段、需要研究或可能跨会话继续时,主动调用 `planning-with-files`,并按该技能在当前项目根目录直接维护 `task_plan.md`、`findings.md` 和 `progress.md`;使用当前会话项目目录的绝对路径,不要写入 Skill 安装目录、进程工作目录、用户目录或 `.niancode/agent-planning/`。', - ].join('\n')); - } - return guidance.join('\n\n'); -} - -function buildLegacyAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): string { - const skills = [ - ' "*": deny', - ...normalizeProjectSkillIds(agent.skillIds) - .filter((skill) => skill !== '*') - .map((skill) => ` ${skill}: allow`), - ].join('\n'); - const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : ''; - const prompt = [ - agent.prompt.trim() || buildProjectAgentPrompt(config, agent), - buildSelectedSkillGuidance(agent.skillIds), - ].filter(Boolean).join('\n\n'); - return `--- -description: ${yamlString(agent.name)} -mode: all -${agent.model ? `model: ${yamlString(agent.model)}\n` : ''}permission: -${shellPermission} # Skills that produce local assets need the bundled CLI, never a remote bootstrap. - skill: -${skills} ---- - -${prompt}`; -} - -export interface LegacyProjectAgentManifestEntry { - relativePath: string; - content: string; -} - -export function buildLegacyProjectAgentManifest( - config: ProjectConfig, -): LegacyProjectAgentManifestEntry[] { - return config.agents - .map((agent) => ({ - relativePath: path.posix.join('agent', `${agent.id}.md`), - content: buildLegacyAgentMarkdown(config, agent), - })) - .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); -} - -function sanitizeLegacyProviderKey(value: string): string { - return value - .trim() - .replace(/[^A-Za-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') - || 'provider'; -} - -function legacyProviderKey(account: Pick): string { - return account.vendorId === 'custom' || account.vendorId === 'ollama' - ? sanitizeLegacyProviderKey(account.id) - : sanitizeLegacyProviderKey(account.vendorId); -} - -function configuredLegacyModelIds(account: ProviderAccount, providerKey: string): Set { - const ids = new Set(); - for (const value of [ - account.model, - ...(account.fallbackModels ?? []), - ...(account.metadata?.customModels ?? []), - ]) { - const normalized = value?.trim(); - if (!normalized) continue; - ids.add(normalized.startsWith(`${providerKey}/`) - ? normalized.slice(providerKey.length + 1) - : normalized); - } - return ids; -} - -export function resolveLegacyProjectModel( - legacyModel: string, - accounts: ProviderAccount[], -): ProductModelRef | null { - const separator = legacyModel.indexOf('/'); - if (separator <= 0 || separator >= legacyModel.length - 1) return null; - const providerKey = legacyModel.slice(0, separator); - const modelId = legacyModel.slice(separator + 1); - const matches = accounts.filter((account) => ( - legacyProviderKey(account) === providerKey - && configuredLegacyModelIds(account, providerKey).has(modelId) - )); - if (matches.length !== 1) return null; - return { - accountId: matches[0].id, - modelId, - thinkingLevel: 'off', - }; -} diff --git a/electron/coding-projects/migration.ts b/electron/coding-projects/migration.ts deleted file mode 100644 index 5c7fbaa..0000000 --- a/electron/coding-projects/migration.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { - copyFile, - mkdir, - readdir, - readFile, - rm, - unlink, -} from 'node:fs/promises'; -import type { Dirent } from 'node:fs'; -import path from 'node:path'; -import type { ProductModelRef } from '../coding-runtime/contracts'; -import { - buildLegacyProjectAgentManifest, - normalizeLegacyProjectConfigV1, -} from './legacy-v1'; -import type { ProjectAgentConfig, ProjectConfig } from '../../shared/project-config'; -import { - atomicWriteJson, - atomicWriteText, - type JsonFileWriter, -} from './atomic-json'; -import { - CODING_PROJECT_CONFIG_PATH, - normalizeCodingProjectConfigV2, - type CodingProjectAgentV2, - type CodingProjectConfigV2, -} from './project-config'; -import { - CODING_CONVERSATIONS_PATH, - createEmptyConversationFileV2, -} from './conversation-store'; - -export interface LegacyModelResolutionInput { - agentId: string; - legacyModel: string; -} - -export type LegacyModelResolver = ( - input: LegacyModelResolutionInput, -) => Promise; - -export interface CodingProjectMigrationDependencies { - resolveLegacyModel: LegacyModelResolver; - now?: () => string; - writeJson?: JsonFileWriter; - copyFile?: typeof copyFile; -} - -export type CodingProjectMigrationResult = - | { - status: 'already-current'; - config: CodingProjectConfigV2; - } - | { - status: 'migrated'; - config: CodingProjectConfigV2; - backupDirectory: string; - removedGeneratedAgents: string[]; - backedUpUncertainAgents: string[]; - }; - -type AgentFileSnapshot = { - fileName: string; - filePath: string; - content: string; - generated: boolean; - backupPath?: string; -}; - -function isMissing(error: unknown): boolean { - return (error as NodeJS.ErrnoException).code === 'ENOENT'; -} - -function timestampKey(value: string): string { - return value.replace(/[^0-9A-Za-z_-]/g, '-'); -} - -async function readOptionalText(filePath: string): Promise { - try { - return await readFile(filePath, 'utf8'); - } catch (error) { - if (isMissing(error)) return null; - throw error; - } -} - -async function snapshotLegacyAgentFiles( - projectPath: string, - config: ProjectConfig, - backupDirectory: string, - copy: typeof copyFile, -): Promise { - const agentDirectory = path.join(projectPath, '.opencode', 'agent'); - const expected = new Map(buildLegacyProjectAgentManifest(config).map((entry) => [ - path.basename(entry.relativePath), - entry.content, - ])); - let entries: Dirent[]; - try { - entries = await readdir(agentDirectory, { withFileTypes: true }); - } catch (error) { - if (isMissing(error)) return []; - throw error; - } - const snapshots: AgentFileSnapshot[] = []; - for (const entry of entries - .filter((candidate) => candidate.isFile()) - .sort((left, right) => left.name.localeCompare(right.name))) { - const fileName = entry.name; - const filePath = path.join(agentDirectory, fileName); - const content = await readOptionalText(filePath); - if (content === null) continue; - const generated = expected.get(fileName) === content; - if (generated) { - snapshots.push({ fileName, filePath, content, generated }); - continue; - } - const backupPath = path.join(backupDirectory, '.opencode', 'agent', fileName); - await mkdir(path.dirname(backupPath), { recursive: true }); - await copy(filePath, backupPath); - snapshots.push({ fileName, filePath, content, generated, backupPath }); - } - return snapshots; -} - -function legacyModelForAgent(config: ProjectConfig, agent: ProjectAgentConfig): string | null { - const direct = typeof agent.model === 'string' ? agent.model.trim() : ''; - const fallback = typeof config.defaultModel === 'string' ? config.defaultModel.trim() : ''; - return direct || fallback || null; -} - -function preserveLegacyAgentFields( - rawProject: unknown, - normalized: ProjectConfig, -): ProjectConfig { - if (!rawProject || typeof rawProject !== 'object' || Array.isArray(rawProject)) return normalized; - const rawAgents = (rawProject as { agents?: unknown }).agents; - if (!Array.isArray(rawAgents)) return normalized; - const rawById = new Map(); - for (const value of rawAgents) { - if (!value || typeof value !== 'object' || Array.isArray(value)) continue; - const record = value as { id?: unknown; prompt?: unknown; skillIds?: unknown }; - const id = typeof record.id === 'string' ? record.id.trim() : ''; - if (id) rawById.set(id, record); - } - return { - ...normalized, - agents: normalized.agents.map((agent) => { - const raw = rawById.get(agent.id); - const rawSkillIds = Array.isArray(raw?.skillIds) - ? [...new Set(raw.skillIds - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter(Boolean))] - : agent.skillIds; - return { - ...agent, - prompt: typeof raw?.prompt === 'string' ? raw.prompt : agent.prompt, - skillIds: rawSkillIds, - }; - }), - }; -} - -async function migrateAgent( - config: ProjectConfig, - agent: ProjectAgentConfig, - resolveLegacyModel: LegacyModelResolver, - fallbackTimestamp: string, -): Promise { - const legacyModel = legacyModelForAgent(config, agent); - const model = legacyModel - ? await resolveLegacyModel({ agentId: agent.id, legacyModel }) - : null; - return { - id: agent.id, - avatarId: agent.avatarId, - ...(agent.avatarDataUrl ? { avatarDataUrl: agent.avatarDataUrl } : {}), - roleName: agent.roleName, - name: agent.name, - builtIn: agent.builtIn, - enabled: agent.enabled, - model, - modelResolution: model ? 'resolved' : 'required', - skillIds: [...agent.skillIds], - responsibility: { - mission: agent.responsibility.mission, - owns: [...agent.responsibility.owns], - boundaries: [...agent.responsibility.boundaries], - collaborators: [...agent.responsibility.collaborators], - principles: [...agent.responsibility.principles], - }, - prompt: agent.prompt, - archivedAt: agent.archivedAt ?? null, - pinned: agent.pinned === true, - createdAt: agent.createdAt ?? fallbackTimestamp, - updatedAt: agent.updatedAt ?? fallbackTimestamp, - }; -} - -async function restoreOriginalState( - projectConfigPath: string, - projectSource: string, - conversationsPath: string, - conversationsSource: string | null, - agentFiles: AgentFileSnapshot[], -): Promise { - const failures: string[] = []; - const restore = async (label: string, operation: () => Promise) => { - try { - await operation(); - } catch (error) { - failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`); - } - }; - await restore('project config', () => atomicWriteText(projectConfigPath, projectSource)); - if (conversationsSource === null) { - await restore('Conversation metadata', () => rm(conversationsPath, { force: true })); - } else { - await restore('Conversation metadata', () => atomicWriteText(conversationsPath, conversationsSource)); - } - for (const agent of agentFiles) { - await restore(`Agent ${agent.fileName}`, () => atomicWriteText(agent.filePath, agent.content)); - } - if (failures.length > 0) { - throw new Error(`Pi cutover rollback was incomplete: ${failures.join('; ')}`); - } -} - -export async function migrateCodingProjectToV2( - projectPath: string, - dependencies: CodingProjectMigrationDependencies, -): Promise { - const projectConfigPath = path.join(projectPath, CODING_PROJECT_CONFIG_PATH); - const conversationsPath = path.join(projectPath, CODING_CONVERSATIONS_PATH); - const projectSource = await readFile(projectConfigPath, 'utf8'); - const rawProject = JSON.parse(projectSource) as unknown; - if (rawProject && typeof rawProject === 'object' && !Array.isArray(rawProject) - && (rawProject as { schemaVersion?: unknown }).schemaVersion === 2) { - return { - status: 'already-current', - config: normalizeCodingProjectConfigV2(rawProject), - }; - } - const normalizedLegacyConfig = normalizeLegacyProjectConfigV1(rawProject); - const legacyConfig = preserveLegacyAgentFields(rawProject, normalizedLegacyConfig); - const conversationsSource = await readOptionalText(conversationsPath); - const migrationTimestamp = dependencies.now?.() ?? new Date().toISOString(); - const backupDirectory = path.join( - projectPath, - '.niancode', - 'migration-backups', - `pi-cutover-${timestampKey(migrationTimestamp)}`, - ); - const copy = dependencies.copyFile ?? copyFile; - const writeJson = dependencies.writeJson ?? atomicWriteJson; - - await mkdir(path.dirname(backupDirectory), { recursive: true }); - await mkdir(backupDirectory, { recursive: false }); - await copy(projectConfigPath, path.join(backupDirectory, 'project.json')); - if (conversationsSource !== null) { - await copy(conversationsPath, path.join(backupDirectory, 'conversations.json')); - } - const agentFiles = await snapshotLegacyAgentFiles( - projectPath, - normalizedLegacyConfig, - backupDirectory, - copy, - ); - - const agents = await Promise.all(legacyConfig.agents.map((agent) => migrateAgent( - legacyConfig, - agent, - dependencies.resolveLegacyModel, - migrationTimestamp, - ))); - const migratedConfig = normalizeCodingProjectConfigV2({ - schemaVersion: 2, - projectType: legacyConfig.projectType, - initialized: legacyConfig.initialized, - agents, - knowledgeDirectory: 'knowledge', - legacyConversationNotice: 'pending', - createdAt: legacyConfig.createdAt, - updatedAt: migrationTimestamp, - }); - - try { - await writeJson(conversationsPath, createEmptyConversationFileV2()); - await writeJson(projectConfigPath, migratedConfig); - for (const agent of agentFiles) await unlink(agent.filePath); - } catch (error) { - try { - await restoreOriginalState( - projectConfigPath, - projectSource, - conversationsPath, - conversationsSource, - agentFiles, - ); - } catch (rollbackError) { - const rollbackMessage = rollbackError instanceof Error - ? rollbackError.message - : String(rollbackError); - const migrationMessage = error instanceof Error ? error.message : String(error); - throw new Error( - `Pi project migration failed (${migrationMessage}) and rollback was incomplete: ${rollbackMessage}`, - { cause: rollbackError }, - ); - } - throw error; - } - - return { - status: 'migrated', - config: migratedConfig, - backupDirectory, - removedGeneratedAgents: agentFiles.filter((agent) => agent.generated).map((agent) => agent.fileName), - backedUpUncertainAgents: agentFiles.filter((agent) => !agent.generated).map((agent) => agent.fileName), - }; -} diff --git a/electron/coding-projects/project-config.ts b/electron/coding-projects/project-config.ts index 8386ce1..8c83952 100644 --- a/electron/coding-projects/project-config.ts +++ b/electron/coding-projects/project-config.ts @@ -17,15 +17,13 @@ import type { } from '../coding-runtime/contracts'; import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json'; -export const CODING_PROJECT_CONFIG_PATH = '.niancode/project.json'; -export const LEGACY_CONVERSATION_NOTICE_VALUES = ['none', 'pending', 'acknowledged'] as const; -export type LegacyConversationNotice = (typeof LEGACY_CONVERSATION_NOTICE_VALUES)[number]; +// Makelore owns project metadata; Pi only consumes the resolved project resources. +export const CODING_PROJECT_CONFIG_PATH = '.makelore/project.json'; export interface CodingProjectAgentV2 extends CodingProjectAgent, ConversationModelState {} export interface CodingProjectConfigV2 extends CodingProjectConfig { agents: CodingProjectAgentV2[]; - legacyConversationNotice: LegacyConversationNotice; } export type CodingProjectConfigReadResult = @@ -188,9 +186,6 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon const createdAt = cleanString(record.createdAt); const updatedAt = cleanString(record.updatedAt); if (!createdAt || !updatedAt) throw new Error('Project timestamps are required'); - if (!LEGACY_CONVERSATION_NOTICE_VALUES.includes(record.legacyConversationNotice as LegacyConversationNotice)) { - throw new Error('Legacy Conversation notice state is invalid'); - } const agents = record.agents.map(normalizeAgent); if (new Set(agents.map((agent) => agent.id)).size !== agents.length) { throw new Error('Duplicate project Agent id'); @@ -202,7 +197,6 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon initialized: record.initialized === true, agents, knowledgeDirectory: 'knowledge', - legacyConversationNotice: record.legacyConversationNotice as LegacyConversationNotice, createdAt, updatedAt, }; @@ -218,7 +212,6 @@ export function createCodingProjectConfigV2( initialized: false, agents: [], knowledgeDirectory: 'knowledge', - legacyConversationNotice: 'none', createdAt: now, updatedAt: now, }; @@ -264,7 +257,7 @@ export async function createCodingProjectMetadata( } const config = createCodingProjectConfigV2(options.now, options.projectType); await Promise.all([ - mkdir(path.join(projectPath, '.niancode'), { recursive: true }), + mkdir(path.join(projectPath, '.makelore'), { recursive: true }), mkdir(path.join(projectPath, 'knowledge'), { recursive: true }), ]); await (options.writer ?? atomicWriteJson)(projectConfigPath(projectPath), config); @@ -301,17 +294,3 @@ export async function createCodingProjectAgent( }, options.writer); return agent; } - -export async function acknowledgeLegacyConversationNotice( - projectPath: string, - options: { now?: string; writer?: JsonFileWriter } = {}, -): Promise { - const result = await readCodingProjectConfigV2(projectPath); - if (result.status !== 'valid') throw new Error('Coding project configuration is missing or invalid'); - if (result.config.legacyConversationNotice !== 'pending') return result.config; - return await writeCodingProjectConfigV2(projectPath, { - ...result.config, - legacyConversationNotice: 'acknowledged', - updatedAt: options.now ?? new Date().toISOString(), - }, options.writer); -} diff --git a/electron/coding-projects/project-service.ts b/electron/coding-projects/project-service.ts index 6d45484..7af65bc 100644 --- a/electron/coding-projects/project-service.ts +++ b/electron/coding-projects/project-service.ts @@ -6,16 +6,11 @@ import { type CodingConversationV2, } from './conversation-store'; import { - acknowledgeLegacyConversationNotice, normalizeCodingProjectConfigV2, readCodingProjectConfigV2, writeCodingProjectConfigV2, type CodingProjectConfigV2, } from './project-config'; -import { - migrateCodingProjectToV2, - type CodingProjectMigrationDependencies, -} from './migration'; import { createLocalCodingProject, type CodingProject, @@ -66,7 +61,6 @@ export interface CodingProjectServiceOptions { ): Promise | void; createConversationStore?: typeof createCodingConversationStore; writeConfig?: typeof writeCodingProjectConfigV2; - migration?: CodingProjectMigrationDependencies; } function requiredAbsolutePath(value: string | undefined, label: string): string { @@ -106,7 +100,6 @@ export class CodingProjectService { ReturnType >(); private activeTransitionTail = Promise.resolve(); - private readonly migrationFlights = new Map>(); constructor( private readonly store: CodingProjectStore, @@ -309,16 +302,6 @@ export class CodingProjectService { return await this.listKnowledgeFiles(project.path); } - async acknowledgeLegacyConversationNotice(projectId: string): Promise { - const project = await this.getProject(projectId); - const config = await acknowledgeLegacyConversationNotice(project.path); - return { - project, - config, - knowledgeFiles: await this.listKnowledgeFiles(project.path), - }; - } - conversationStore(projectPath: string): ReturnType { const existing = this.conversationStores.get(projectPath); if (existing) return existing; @@ -355,19 +338,7 @@ export class CodingProjectService { private async readCurrentConfig(projectPath: string): Promise { const current = await readCodingProjectConfigV2(projectPath); - if (current.status === 'valid') return current.config; - if (current.status !== 'invalid' || !this.options.migration) return null; - const existing = this.migrationFlights.get(projectPath); - if (existing) return await existing; - const flight = migrateCodingProjectToV2(projectPath, this.options.migration) - .then((result) => result.config) - .finally(() => { - if (this.migrationFlights.get(projectPath) === flight) { - this.migrationFlights.delete(projectPath); - } - }); - this.migrationFlights.set(projectPath, flight); - return await flight; + return current.status === 'valid' ? current.config : null; } private transitionActiveProject(operation: () => Promise<{ diff --git a/electron/coding-projects/project-store.ts b/electron/coding-projects/project-store.ts index aefe4c6..052e674 100644 --- a/electron/coding-projects/project-store.ts +++ b/electron/coding-projects/project-store.ts @@ -73,8 +73,7 @@ export function createCodingProjectStorageFromStore( export async function createElectronCodingProjectStorage(): Promise { const Store = (await import('electron-store')).default; const store = new Store<{ projects?: CodingProjectStoreData }>({ - // Legacy installed-user storage identity. Renaming it would orphan projects. - name: 'opencode-projects', + name: 'makelore-projects', }); return createCodingProjectStorageFromStore(store, 'projects'); } diff --git a/electron/coding-projects/skill-registry.ts b/electron/coding-projects/skill-registry.ts index eee71db..71abff5 100644 --- a/electron/coding-projects/skill-registry.ts +++ b/electron/coding-projects/skill-registry.ts @@ -74,16 +74,16 @@ export async function listProductCodingSkills( ): Promise { const selected = selectedSkillIds(selectedIds); return await Promise.all(BUNDLED_CODING_SKILL_IDS.map(async (id) => { - const location = path.join(bundledSkillsDir, id); - const content = await readFile(path.join(location, 'SKILL.md'), 'utf8'); + const skillDirectory = path.join(bundledSkillsDir, id); + const content = await readFile(path.join(skillDirectory, 'SKILL.md'), 'utf8'); return { id, name: frontmatterScalar(content, 'name') ?? id, description: frontmatterScalar(content, 'description') ?? '', selected: selected.has(id), - location, + location: path.posix.join('resources', 'coding-skills', id), content, - entries: await listSkillEntries(location), + entries: await listSkillEntries(skillDirectory), }; })); } diff --git a/electron/coding-runtime/in-memory-conversation-runtime.ts b/electron/coding-runtime/in-memory-conversation-runtime.ts index c6de1d4..8cef7e3 100644 --- a/electron/coding-runtime/in-memory-conversation-runtime.ts +++ b/electron/coding-runtime/in-memory-conversation-runtime.ts @@ -302,6 +302,8 @@ export class InMemoryConversationRuntime implements CodingConversationRuntime { run: { status: 'idle', ...(current.runId ? { runId: current.runId } : {}), + ...(current.mode ? { mode: current.mode } : {}), + ...(current.startedAt !== undefined ? { startedAt: current.startedAt } : {}), settledAt: this.now(), terminalReason: 'aborted', }, diff --git a/electron/coding-runtime/pi/agent-server-process.ts b/electron/coding-runtime/pi/agent-server-process.ts new file mode 100644 index 0000000..2304a3d --- /dev/null +++ b/electron/coding-runtime/pi/agent-server-process.ts @@ -0,0 +1,760 @@ +import { + execFile, + spawn, + type ChildProcessWithoutNullStreams, +} from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { platform } from 'node:os'; +import { Writable } from 'node:stream'; +import { logger } from '../../utils/logger'; +import { PiProcessError } from './process-errors'; +import { + PiRpcClient, + type PiRpcCommand, + type PiRpcEvent, + type PiRpcRequestOptions, + type PiRpcResponse, +} from './rpc-client'; +import { StrictLfJsonlFramer } from './rpc-framer'; +import { + buildPiWorkerEnvironment, + DEFAULT_PI_RPC_TOOLS, + sanitizePiDiagnostic, + type PiWorkerLifecycleEvent, + type PiWorkerProcessOptions, + type PiWorkerProofFailure, + type PiWorkerStopReason, + type PiWorkerStopResult, +} from './worker-process'; + +const SERVER_CHANNEL = '@makelore/server'; +const SERVER_START_TIMEOUT_MS = 60_000; +const SERVER_CONTROL_TIMEOUT_MS = 30_000; +const SERVER_SHUTDOWN_GRACE_MS = 3_000; +const SERVER_DIAGNOSTIC_BYTES = 16_000; + +interface AgentServerEnvelope { + channel: string; + payload: unknown; +} + +interface AgentServerThreadOptions { + cwd: string; + configDir: string; + sessionDir: string; + tools?: readonly string[]; + additionalArgs: readonly string[]; + env: Record; + conversationId?: string; + workerGeneration: number; +} + +export interface PiAgentServerProcessOptions { + executablePath: string; + serverPath: string; + runtimeRoot: string; + configDir: string; + shutdownGraceMs?: number; + diagnosticBytes?: number; +} + +interface ChannelRecord { + rpc: PiRpcClient; + worker?: PiAgentServerWorkerProcess; +} + +function positiveInteger(value: number | undefined, fallback: number, name: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved <= 0) { + throw new Error(`${name} must be a positive safe integer`); + } + return resolved; +} + +function boundedUtf8Tail(source: string, maxBytes: number): string { + const bytes = Buffer.from(source, 'utf8'); + if (bytes.length <= maxBytes) return source; + const decoder = new TextDecoder('utf-8', { fatal: true }); + for (let start = bytes.length - maxBytes; start < bytes.length; start += 1) { + try { + return decoder.decode(bytes.subarray(start)); + } catch { + // Advance to the next UTF-8 code point boundary. + } + } + return ''; +} + +function envelopeValue(value: unknown): value is AgentServerEnvelope { + return Boolean(value) + && typeof value === 'object' + && !Array.isArray(value) + && typeof (value as { channel?: unknown }).channel === 'string' + && 'payload' in value; +} + +function stringEnvironment(value: NodeJS.ProcessEnv | undefined): Record { + return Object.fromEntries(Object.entries(value ?? {}).flatMap(([key, candidate]) => ( + typeof candidate === 'string' ? [[key, candidate]] : [] + ))); +} + +function diagnosticPathAliases(value: string | undefined): string[] { + if (!value) return []; + try { + const canonical = realpathSync.native(value); + return canonical === value ? [value] : [value, canonical]; + } catch { + return [value]; + } +} + +function diagnosticSensitiveValues(options: PiWorkerProcessOptions): string[] { + return [...new Set([ + ...(options.sensitiveValues ?? []), + ...[ + options.cwd, + options.cliPath, + options.configDir, + options.sessionDir, + options.env?.HOME, + options.env?.USERPROFILE, + options.env?.APPDATA, + options.env?.LOCALAPPDATA, + ].flatMap(diagnosticPathAliases), + ].filter((value): value is string => Boolean(value)))]; +} + +function runExecutable(executable: string, args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + execFile(executable, [...args], { windowsHide: true }, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +class AgentServerChannelWritable extends Writable { + constructor( + private readonly channel: string, + private readonly sendEnvelope: (envelope: AgentServerEnvelope) => Promise, + ) { + super(); + } + + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + try { + const source = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk; + const line = source.endsWith('\n') ? source.slice(0, -1) : source; + if (!line || line.includes('\n')) throw new Error('Agent Server channel write must contain one JSONL record'); + const payload = JSON.parse(line) as unknown; + void this.sendEnvelope({ channel: this.channel, payload }).then( + () => callback(), + (error: unknown) => callback(error instanceof Error ? error : new Error(String(error))), + ); + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))); + } + } +} + +export class PiAgentServerWorkerProcess { + readonly generation: number; + private readonly invalidationListeners = new Set<(error: PiProcessError) => void>(); + private rpc: PiRpcClient | null = null; + private invalidation: PiProcessError | null = null; + private stopFlight: Promise | null = null; + private diagnostic = ''; + private failureLifecycleRecorded = false; + + constructor( + private readonly server: PiAgentServerProcess, + readonly options: PiWorkerProcessOptions, + ) { + this.generation = positiveInteger(options.workerGeneration, 1, 'workerGeneration'); + } + + get threadId(): string { + return `${this.options.conversationId ?? 'conversation'}:${this.generation}`; + } + + get processId(): number | undefined { + return this.server.processId; + } + + get isRunning(): boolean { + return Boolean(this.rpc) && !this.invalidation && this.server.isRunning; + } + + get stderrDiagnostic(): string { + return this.diagnostic; + } + + async start(): Promise { + if (this.rpc) throw new Error('Pi Agent Server thread already started'); + this.rpc = await this.server.openThread(this); + return this; + } + + request( + command: PiRpcCommand, + options?: PiRpcRequestOptions, + ): Promise> { + if (this.invalidation) return Promise.reject(this.invalidation); + if (!this.rpc) { + return Promise.reject(new PiProcessError( + 'PI_WORKER_START_FAILED', + 'Pi Agent Server thread has not started', + { generation: this.generation }, + )); + } + return this.rpc.request(command, options); + } + + send(command: PiRpcCommand): Promise { + if (this.invalidation) return Promise.reject(this.invalidation); + if (!this.rpc) { + return Promise.reject(new PiProcessError( + 'PI_WORKER_START_FAILED', + 'Pi Agent Server thread has not started', + { generation: this.generation }, + )); + } + return this.rpc.send(command); + } + + subscribe(listener: (event: PiRpcEvent) => void): () => void { + if (!this.rpc) throw new Error('Pi Agent Server thread has not started'); + return this.rpc.subscribe(listener); + } + + subscribeInvalidation(listener: (error: PiProcessError) => void): () => void { + this.invalidationListeners.add(listener); + return () => this.invalidationListeners.delete(listener); + } + + stop(reason: PiWorkerStopReason): Promise { + if (!this.stopFlight) this.stopFlight = this.performStop(reason); + return this.stopFlight; + } + + async injectFailureForProof(failure: PiWorkerProofFailure): Promise { + this.appendDiagnostic( + '[release-proof] worker failure Authorization: Bearer packaged-proof-secret\n', + ); + await this.server.invalidateThreadForProof(this, failure); + } + + delayNextResponseForProof(commandType: string, delayMs: number): void { + if (!this.rpc) throw new Error('Pi Agent Server thread has not started'); + this.rpc.delayNextResponseForProof(commandType, delayMs); + } + + invalidate(error: PiProcessError): void { + if (this.invalidation) return; + this.invalidation = error; + if (!this.failureLifecycleRecorded + && (error.code === 'PI_RPC_PROTOCOL_ERROR' || error.code === 'PI_RPC_EXITED')) { + this.failureLifecycleRecorded = true; + this.recordLifecycle({ + classification: error.code === 'PI_RPC_PROTOCOL_ERROR' + ? 'protocol_invalidation' + : 'unexpected_exit', + stage: error.code === 'PI_RPC_PROTOCOL_ERROR' ? 'protocol' : 'close', + generation: this.generation, + code: error.code, + exitCode: null, + signal: null, + ...(this.diagnostic ? { diagnostic: this.diagnostic } : {}), + }); + } + this.rpc?.invalidate(error); + for (const listener of this.invalidationListeners) { + try { + listener(error); + } catch { + // Thread invalidation observers must not affect sibling threads. + } + } + } + + private async performStop(reason: PiWorkerStopReason): Promise { + const classification = this.invalidation?.code === 'PI_RPC_PROTOCOL_ERROR' + ? 'protocol_invalidation' + : this.invalidation?.code === 'PI_RPC_EXITED' + ? 'unexpected_exit' + : 'intentional_stop'; + const code = this.invalidation?.code ?? 'PI_WORKER_STOPPED'; + this.recordLifecycle({ + classification, + stage: 'stop_requested', + generation: this.generation, + code, + reason, + exitCode: null, + signal: null, + ...(this.diagnostic ? { diagnostic: this.diagnostic } : {}), + }); + try { + const result = await this.server.closeThread(this, reason); + this.recordLifecycle({ + classification, + stage: 'stop_completed', + generation: this.generation, + code, + reason, + exitCode: result.code, + signal: result.signal, + ...(this.diagnostic ? { diagnostic: this.diagnostic } : {}), + }); + return result; + } catch (error) { + this.recordLifecycle({ + classification, + stage: 'stop_failed', + generation: this.generation, + code: 'PI_WORKER_STOP_FAILED', + reason, + exitCode: null, + signal: null, + ...(this.diagnostic ? { diagnostic: this.diagnostic } : {}), + }); + throw error; + } + } + + private appendDiagnostic(source: string): void { + this.diagnostic = boundedUtf8Tail( + `${this.diagnostic}${sanitizePiDiagnostic(source, diagnosticSensitiveValues(this.options))}`, + SERVER_DIAGNOSTIC_BYTES, + ); + } + + private recordLifecycle(event: PiWorkerLifecycleEvent): void { + const diagnostic = event.diagnostic + ? boundedUtf8Tail( + sanitizePiDiagnostic(event.diagnostic, diagnosticSensitiveValues(this.options)), + SERVER_DIAGNOSTIC_BYTES, + ) + : undefined; + const safeEvent: PiWorkerLifecycleEvent = { + ...event, + ...(this.options.conversationId ? { conversationId: this.options.conversationId } : {}), + ...(diagnostic ? { diagnostic } : {}), + }; + try { + this.options.onLifecycleEvent?.(structuredClone(safeEvent)); + } catch { + // Lifecycle observers must not affect logical thread cleanup. + } + logger.warn('[PiWorkerLifecycle]', safeEvent); + } +} + +export class PiAgentServerProcess { + private readonly shutdownGraceMs: number; + private readonly diagnosticBytes: number; + private readonly channels = new Map(); + private readonly retiredChannels = new Set(); + private readonly sensitiveValues = new Set(); + private child: ChildProcessWithoutNullStreams | null = null; + private control: PiRpcClient | null = null; + private startFlight: Promise | null = null; + private stopFlight: Promise | null = null; + private exitResult: Promise<{ code: number | null; signal: NodeJS.Signals | null }> | null = null; + private diagnostic = ''; + private generation = 0; + private stopping = false; + private failure: PiProcessError | null = null; + + constructor(private readonly options: PiAgentServerProcessOptions) { + this.shutdownGraceMs = positiveInteger( + options.shutdownGraceMs, + SERVER_SHUTDOWN_GRACE_MS, + 'shutdownGraceMs', + ); + this.diagnosticBytes = positiveInteger( + options.diagnosticBytes, + SERVER_DIAGNOSTIC_BYTES, + 'diagnosticBytes', + ); + } + + get processId(): number | undefined { + return this.child?.pid; + } + + get isRunning(): boolean { + return Boolean(this.child) && !this.failure; + } + + get stderrDiagnostic(): string { + return this.diagnostic; + } + + get activeThreadCount(): number { + return [...this.channels.values()].filter(({ worker }) => Boolean(worker)).length; + } + + createWorker(options: PiWorkerProcessOptions): PiAgentServerWorkerProcess { + return new PiAgentServerWorkerProcess(this, options); + } + + async openThread(worker: PiAgentServerWorkerProcess): Promise { + await this.start(); + const control = this.control; + if (!control) throw new PiProcessError('PI_WORKER_START_FAILED', 'Pi Agent Server is unavailable'); + if (this.channels.has(worker.threadId)) throw new Error('Pi Agent Server thread already exists'); + for (const value of diagnosticSensitiveValues(worker.options)) { + if (value) this.sensitiveValues.add(value); + } + const writable = new AgentServerChannelWritable( + worker.threadId, + async (envelope) => await this.writeEnvelope(envelope), + ); + const rpc = new PiRpcClient(writable, { + generation: worker.generation, + defaultTimeoutMs: worker.options.commandTimeoutMs, + idPrefix: 'makelore-thread', + onEventListenerError: (error) => { + this.appendDiagnostic(`[thread-listener] ${error instanceof Error ? error.message : String(error)}\n`); + }, + }); + this.channels.set(worker.threadId, { rpc, worker }); + try { + await control.request({ + type: 'thread_open', + threadId: worker.threadId, + options: this.threadOptions(worker.options), + }, { timeoutMs: SERVER_START_TIMEOUT_MS }); + return rpc; + } catch (error) { + this.channels.delete(worker.threadId); + this.retireChannel(worker.threadId); + const failure = error instanceof PiProcessError + ? error + : new PiProcessError('PI_WORKER_START_FAILED', 'Could not open Pi Agent Server thread', { + cause: error, + generation: worker.generation, + }); + rpc.invalidate(failure); + throw failure; + } + } + + async closeThread( + worker: PiAgentServerWorkerProcess, + _reason: PiWorkerStopReason, + ): Promise { + const record = this.channels.get(worker.threadId); + if (!record) return { mode: 'not-started', code: null, signal: null }; + const control = this.control; + try { + if (control && !this.failure) { + await control.request({ + type: 'thread_close', + threadId: worker.threadId, + }, { timeoutMs: SERVER_CONTROL_TIMEOUT_MS }); + } + } finally { + this.channels.delete(worker.threadId); + this.retireChannel(worker.threadId); + worker.invalidate(new PiProcessError('PI_WORKER_STOPPED', 'Pi Agent Server thread stopped', { + generation: worker.generation, + })); + } + return { mode: 'stdin-close', code: 0, signal: null }; + } + + async invalidateThreadForProof( + worker: PiAgentServerWorkerProcess, + failure: PiWorkerProofFailure, + ): Promise { + await this.control?.request({ + type: 'thread_close', + threadId: worker.threadId, + }, { timeoutMs: SERVER_CONTROL_TIMEOUT_MS }).catch(() => undefined); + this.channels.delete(worker.threadId); + this.retireChannel(worker.threadId); + worker.invalidate(new PiProcessError( + failure === 'protocol_invalidation' ? 'PI_RPC_PROTOCOL_ERROR' : 'PI_RPC_EXITED', + failure === 'protocol_invalidation' + ? 'Injected Agent Server thread protocol failure' + : 'Injected Agent Server thread exit', + { generation: worker.generation }, + )); + } + + start(): Promise { + if (this.startFlight) return this.startFlight; + if (this.child && !this.failure) return Promise.resolve(); + this.startFlight = this.startServer().finally(() => { + this.startFlight = null; + }); + return this.startFlight; + } + + stop(): Promise { + if (!this.stopFlight) { + this.stopFlight = this.stopServer().finally(() => { + this.stopFlight = null; + }); + } + return this.stopFlight; + } + + private async startServer(): Promise { + this.failure = null; + this.stopping = false; + this.diagnostic = ''; + const generation = ++this.generation; + const child = spawn( + this.options.executablePath, + [this.options.serverPath, '--runtime-root', this.options.runtimeRoot], + { + cwd: this.options.runtimeRoot, + env: buildPiWorkerEnvironment(this.options.configDir), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + detached: platform() !== 'win32', + }, + ); + this.child = child; + this.exitResult = new Promise((resolve) => { + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + const writable = new AgentServerChannelWritable( + SERVER_CHANNEL, + async (envelope) => await this.writeEnvelope(envelope), + ); + const control = new PiRpcClient(writable, { + generation, + defaultTimeoutMs: SERVER_CONTROL_TIMEOUT_MS, + idPrefix: 'makelore-server', + onEventListenerError: (error) => { + this.appendDiagnostic(`[server-listener] ${error instanceof Error ? error.message : String(error)}\n`); + }, + }); + this.control = control; + this.channels.set(SERVER_CHANNEL, { rpc: control }); + + child.stderr.on('data', (chunk: Buffer) => this.appendDiagnostic(chunk.toString('utf8'))); + const framer = new StrictLfJsonlFramer({ + onRecord: (value) => this.acceptEnvelope(value), + }); + child.stdout.on('data', (chunk: Buffer) => { + try { + framer.push(chunk); + } catch (error) { + this.handleProtocolFailure(error); + } + }); + child.stdout.on('end', () => { + try { + framer.finish(); + } catch (error) { + this.handleProtocolFailure(error); + } + }); + child.once('close', (code, signal) => { + if (this.child !== child) return; + const wasStopping = this.stopping; + if (!wasStopping && !this.failure) { + this.invalidateServer(new PiProcessError( + 'PI_RPC_EXITED', + `Pi Agent Server exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'})`, + { generation, diagnostic: this.diagnostic || undefined, exitCode: code, signal }, + )); + } + this.child = null; + this.control = null; + this.exitResult = null; + }); + + try { + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); + await control.request({ type: 'server_initialize' }, { timeoutMs: SERVER_START_TIMEOUT_MS }); + logger.info('[PiAgentServerLifecycle]', { + event: 'server.ready', + generation, + processId: child.pid, + }); + } catch (error) { + const failure = error instanceof PiProcessError + ? error + : new PiProcessError('PI_WORKER_START_FAILED', 'Could not start Pi Agent Server', { + cause: error, + generation, + diagnostic: this.diagnostic || undefined, + }); + this.invalidateServer(failure); + await this.forceKillTree(child); + throw failure; + } + } + + private async stopServer(): Promise { + if (this.startFlight) await this.startFlight.catch(() => undefined); + const child = this.child; + const exitResult = this.exitResult; + if (!child || !exitResult) { + this.resetTransport(); + return; + } + this.stopping = true; + await this.control?.request( + { type: 'server_shutdown' }, + { timeoutMs: SERVER_CONTROL_TIMEOUT_MS }, + ).catch(() => undefined); + child.stdin.end(); + const graceful = await this.waitForExit(exitResult, this.shutdownGraceMs); + if (!graceful) { + await this.forceKillTree(child); + await this.waitForExit(exitResult, this.shutdownGraceMs); + } + logger.info('[PiAgentServerLifecycle]', { + event: 'server.stopped', + generation: this.generation, + }); + this.resetTransport(); + } + + private threadOptions(options: PiWorkerProcessOptions): AgentServerThreadOptions { + return { + cwd: options.cwd, + configDir: options.configDir, + sessionDir: options.sessionDir, + tools: [...(options.tools ?? DEFAULT_PI_RPC_TOOLS)], + additionalArgs: [...(options.additionalArgs ?? [])], + env: stringEnvironment(options.env), + ...(options.conversationId ? { conversationId: options.conversationId } : {}), + workerGeneration: positiveInteger(options.workerGeneration, 1, 'workerGeneration'), + }; + } + + private acceptEnvelope(value: unknown): void { + if (!envelopeValue(value)) { + throw new PiProcessError('PI_RPC_PROTOCOL_ERROR', 'Pi Agent Server envelope is invalid'); + } + const record = this.channels.get(value.channel); + if (!record) { + if (this.retiredChannels.has(value.channel)) return; + throw new PiProcessError( + 'PI_RPC_PROTOCOL_ERROR', + `Pi Agent Server emitted an unknown channel ${value.channel}`, + ); + } + record.rpc.accept(value.payload); + } + + private handleProtocolFailure(error: unknown): void { + if (this.failure) return; + const message = error instanceof Error ? error.message : 'Pi Agent Server protocol failure'; + this.appendDiagnostic(`[stdout-protocol] ${message}\n`); + this.invalidateServer(new PiProcessError('PI_RPC_PROTOCOL_ERROR', message, { + cause: error, + generation: this.generation, + diagnostic: this.diagnostic || undefined, + })); + if (this.child) void this.forceKillTree(this.child); + } + + private invalidateServer(error: PiProcessError): void { + if (this.failure) return; + this.failure = error; + this.control?.invalidate(error); + for (const [channel, record] of [...this.channels]) { + if (channel === SERVER_CHANNEL) continue; + record.worker?.invalidate(error); + this.channels.delete(channel); + this.retireChannel(channel); + } + logger.warn('[PiAgentServerLifecycle]', { + event: 'server.invalidated', + generation: this.generation, + code: error.code, + ...(this.diagnostic ? { diagnostic: this.diagnostic } : {}), + }); + } + + private writeEnvelope(envelope: AgentServerEnvelope): Promise { + const child = this.child; + if (!child || child.stdin.destroyed || this.failure) { + return Promise.reject(this.failure ?? new PiProcessError( + 'PI_RPC_WRITE_FAILED', + 'Pi Agent Server input is unavailable', + { generation: this.generation }, + )); + } + return new Promise((resolve, reject) => { + child.stdin.write(`${JSON.stringify(envelope)}\n`, (error?: Error | null) => { + if (error) reject(error); + else resolve(); + }); + }); + } + + private appendDiagnostic(source: string): void { + const sanitized = sanitizePiDiagnostic(source, [...this.sensitiveValues]); + this.diagnostic = boundedUtf8Tail(`${this.diagnostic}${sanitized}`, this.diagnosticBytes); + } + + private retireChannel(channel: string): void { + this.retiredChannels.add(channel); + if (this.retiredChannels.size <= 256) return; + const oldest = this.retiredChannels.values().next().value as string | undefined; + if (oldest) this.retiredChannels.delete(oldest); + } + + private resetTransport(): void { + const stopped = new PiProcessError('PI_WORKER_STOPPED', 'Pi Agent Server stopped', { + generation: this.generation, + }); + for (const record of this.channels.values()) record.rpc.invalidate(stopped); + this.channels.clear(); + this.retiredChannels.clear(); + this.child = null; + this.control = null; + this.exitResult = null; + this.failure = null; + this.stopping = false; + this.sensitiveValues.clear(); + } + + private async forceKillTree(child: ChildProcessWithoutNullStreams): Promise { + if (!child.pid) return; + if (platform() === 'win32') { + await runExecutable('taskkill.exe', ['/pid', String(child.pid), '/t', '/f']).catch(() => undefined); + return; + } + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } + } + + private async waitForExit( + exitResult: Promise<{ code: number | null; signal: NodeJS.Signals | null }>, + timeoutMs: number, + ): Promise<{ code: number | null; signal: NodeJS.Signals | null } | null> { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + exitResult, + new Promise((resolve) => { + timeout = setTimeout(() => resolve(null), timeoutMs); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } +} diff --git a/electron/coding-runtime/pi/event-projector.ts b/electron/coding-runtime/pi/event-projector.ts index b1f4559..72ee270 100644 --- a/electron/coding-runtime/pi/event-projector.ts +++ b/electron/coding-runtime/pi/event-projector.ts @@ -460,6 +460,8 @@ export class PiEventProjector { run: { status: 'idle', ...(snapshot.run.runId ? { runId: snapshot.run.runId } : {}), + ...(snapshot.run.mode ? { mode: snapshot.run.mode } : {}), + ...(snapshot.run.startedAt !== undefined ? { startedAt: snapshot.run.startedAt } : {}), settledAt: this.now(), terminalReason, ...(terminalReason === 'failed' && snapshot.run.error diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index 36767d8..098f177 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -227,6 +227,7 @@ export class PiManagedExtensionHost { MAKELORE_PI_WORKER_TOKEN: token, MAKELORE_PI_CONTEXT_FILE: contextFile, MAKELORE_PI_WORKER_ROLE: role, + ...(record.projectPath ? { MAKELORE_PI_PROJECT_PATH: record.projectPath } : {}), }, sensitiveValues: [token], dispose: async () => { diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts index 97ced02..1178c96 100644 --- a/electron/coding-runtime/pi/extensions/makelore-runtime.ts +++ b/electron/coding-runtime/pi/extensions/makelore-runtime.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { atomicWriteText } from '../../../coding-projects/atomic-json'; -export const MAKELORE_PI_EXTENSION_VERSION = 3; +export const MAKELORE_PI_EXTENSION_VERSION = 4; export const MAKELORE_PI_EXTENSION_FILENAME = `makelore-runtime-v${MAKELORE_PI_EXTENSION_VERSION}.mjs`; const BUNDLE_SOURCE = String.raw` @@ -15,118 +15,156 @@ const MUTATION_TOOLS = new Set([ 'game_asset_browser', 'game_asset_review', ]); -const WORKER_ROLE = process.env.MAKELORE_PI_WORKER_ROLE || 'parent'; -const leases = new Map(); -const touchedPaths = new Map(); - -async function runtimeContext() { - const value = JSON.parse(await readFile(process.env.MAKELORE_PI_CONTEXT_FILE, 'utf8')); - if (!value.runId) throw new Error('Makelore run context is unavailable'); - return value; -} - -async function bridge(action, body, signal) { - const context = await runtimeContext(); - const response = await fetch(process.env.MAKELORE_PI_BRIDGE_URL, { - method: 'POST', - headers: { - authorization: 'Bearer ' + process.env.MAKELORE_PI_WORKER_TOKEN, - 'content-type': 'application/json', - }, - body: JSON.stringify({ ...context, action, ...body }), - signal, - }); - const result = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(result.error || 'Makelore runtime bridge rejected the request'); - return result; -} - -async function bridgeStream(action, body, signal, onUpdate) { - const context = await runtimeContext(); - const response = await fetch(process.env.MAKELORE_PI_BRIDGE_URL, { - method: 'POST', - headers: { - authorization: 'Bearer ' + process.env.MAKELORE_PI_WORKER_TOKEN, - 'content-type': 'application/json', - }, - body: JSON.stringify({ ...context, action, ...body }), - signal, - }); - if (!response.ok) { - const result = await response.json().catch(() => ({})); - throw new Error(result.error || 'Makelore runtime bridge rejected the request'); - } - if (!response.body) throw new Error('Makelore runtime bridge returned no stream'); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffered = ''; - let details; - while (true) { - const { value, done } = await reader.read(); - buffered += decoder.decode(value || new Uint8Array(), { stream: !done }); - let newline = buffered.indexOf('\n'); - while (newline >= 0) { - const line = buffered.slice(0, newline); - buffered = buffered.slice(newline + 1); - if (line) { - const item = JSON.parse(line); - if (item.details?.schema === 'subagent.v1') { - details = item.details; - onUpdate?.({ content: [], details }); - } - } - newline = buffered.indexOf('\n'); - } - if (done) break; - } - if (!details) throw new Error('Makelore subagent stream returned no details'); - return details; -} - -async function releaseLease(toolCallId) { - const leaseId = leases.get(toolCallId); - if (!leaseId) return; - leases.delete(toolCallId); - await bridge('lease.release', { leaseId, resourceId: toolCallId }).catch(() => undefined); -} - -async function releaseAll() { - await Promise.all([...leases.keys()].map(releaseLease)); - touchedPaths.clear(); -} - -function projectRelativePath(value) { - if (typeof value !== 'string' || !value.trim()) return undefined; - const projectPath = path.resolve(process.cwd()); - const targetPath = path.resolve(projectPath, value); - const relative = path.relative(projectPath, targetPath); - if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return undefined; - return relative.split(path.sep).join('/'); -} - -async function invokeProduct(toolCallId, toolName, input, signal) { - const response = await bridge('product.invoke', { - resourceId: toolCallId, - toolName, - input, - }, signal); - return response.result; -} - -function registerProductTool(pi, name, label, description, parameters) { - pi.registerTool({ - name, - label, - description, - parameters, - async execute(toolCallId, params, signal) { - return await invokeProduct(toolCallId, name, params, signal); - }, - }); -} +const RUNTIME_FLAGS = { + bridgeUrl: 'makelore-bridge-url', + workerToken: 'makelore-worker-token', + contextFile: 'makelore-context-file', + workerRole: 'makelore-worker-role', + projectPath: 'makelore-project-path', +}; export default function makeloreRuntime(pi) { - if (WORKER_ROLE === 'parent') pi.registerTool({ + const fallbackFlagValues = new Map(); + const registerRuntimeFlag = (name, defaultValue) => { + fallbackFlagValues.set(name, defaultValue); + if (typeof pi.registerFlag === 'function') { + pi.registerFlag(name, { type: 'string', default: defaultValue }); + } + }; + registerRuntimeFlag(RUNTIME_FLAGS.bridgeUrl, process.env.MAKELORE_PI_BRIDGE_URL || ''); + registerRuntimeFlag(RUNTIME_FLAGS.workerToken, process.env.MAKELORE_PI_WORKER_TOKEN || ''); + registerRuntimeFlag(RUNTIME_FLAGS.contextFile, process.env.MAKELORE_PI_CONTEXT_FILE || ''); + registerRuntimeFlag(RUNTIME_FLAGS.workerRole, process.env.MAKELORE_PI_WORKER_ROLE || 'parent'); + registerRuntimeFlag( + RUNTIME_FLAGS.projectPath, + process.env.MAKELORE_PI_PROJECT_PATH || process.cwd(), + ); + + const runtimeValue = (name) => { + const value = typeof pi.getFlag === 'function' + ? pi.getFlag(name) + : fallbackFlagValues.get(name); + return typeof value === 'string' ? value : ''; + }; + const workerRole = runtimeValue(RUNTIME_FLAGS.workerRole) || 'parent'; + const leases = new Map(); + const touchedPaths = new Map(); + + async function runtimeContext() { + const contextFile = runtimeValue(RUNTIME_FLAGS.contextFile); + if (!contextFile) throw new Error('Makelore runtime context file is unavailable'); + const value = JSON.parse(await readFile(contextFile, 'utf8')); + if (!value.runId) throw new Error('Makelore run context is unavailable'); + return value; + } + + async function bridge(action, body, signal) { + const context = await runtimeContext(); + const bridgeUrl = runtimeValue(RUNTIME_FLAGS.bridgeUrl); + const workerToken = runtimeValue(RUNTIME_FLAGS.workerToken); + if (!bridgeUrl || !workerToken) throw new Error('Makelore runtime bridge is unavailable'); + const response = await fetch(bridgeUrl, { + method: 'POST', + headers: { + authorization: 'Bearer ' + workerToken, + 'content-type': 'application/json', + }, + body: JSON.stringify({ ...context, action, ...body }), + signal, + }); + const result = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(result.error || 'Makelore runtime bridge rejected the request'); + return result; + } + + async function bridgeStream(action, body, signal, onUpdate) { + const context = await runtimeContext(); + const bridgeUrl = runtimeValue(RUNTIME_FLAGS.bridgeUrl); + const workerToken = runtimeValue(RUNTIME_FLAGS.workerToken); + if (!bridgeUrl || !workerToken) throw new Error('Makelore runtime bridge is unavailable'); + const response = await fetch(bridgeUrl, { + method: 'POST', + headers: { + authorization: 'Bearer ' + workerToken, + 'content-type': 'application/json', + }, + body: JSON.stringify({ ...context, action, ...body }), + signal, + }); + if (!response.ok) { + const result = await response.json().catch(() => ({})); + throw new Error(result.error || 'Makelore runtime bridge rejected the request'); + } + if (!response.body) throw new Error('Makelore subagent stream returned no body'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ''; + let details; + while (true) { + const { value, done } = await reader.read(); + buffered += decoder.decode(value || new Uint8Array(), { stream: !done }); + let newline = buffered.indexOf('\n'); + while (newline >= 0) { + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + if (line) { + const item = JSON.parse(line); + if (item.details?.schema === 'subagent.v1') { + details = item.details; + onUpdate?.({ content: [], details }); + } + } + newline = buffered.indexOf('\n'); + } + if (done) break; + } + if (!details) throw new Error('Makelore subagent stream returned no details'); + return details; + } + + async function releaseLease(toolCallId) { + const leaseId = leases.get(toolCallId); + if (!leaseId) return; + leases.delete(toolCallId); + await bridge('lease.release', { leaseId, resourceId: toolCallId }).catch(() => undefined); + } + + async function releaseAll() { + await Promise.all([...leases.keys()].map(releaseLease)); + touchedPaths.clear(); + } + + function projectRelativePath(value) { + if (typeof value !== 'string' || !value.trim()) return undefined; + const projectPath = path.resolve(runtimeValue(RUNTIME_FLAGS.projectPath) || process.cwd()); + const targetPath = path.resolve(projectPath, value); + const relative = path.relative(projectPath, targetPath); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return undefined; + return relative.split(path.sep).join('/'); + } + + async function invokeProduct(toolCallId, toolName, input, signal) { + const response = await bridge('product.invoke', { + resourceId: toolCallId, + toolName, + input, + }, signal); + return response.result; + } + + function registerProductTool(name, label, description, parameters) { + pi.registerTool({ + name, + label, + description, + parameters, + async execute(toolCallId, params, signal) { + return await invokeProduct(toolCallId, name, params, signal); + }, + }); + } + + if (workerRole === 'parent') pi.registerTool({ name: 'ask_user', label: 'Ask user', description: 'Ask the user for a selection, confirmation, short input, or editor text.', @@ -159,7 +197,7 @@ export default function makeloreRuntime(pi) { }, }); - if (WORKER_ROLE === 'parent') pi.registerTool({ + if (workerRole === 'parent') pi.registerTool({ name: 'subagent', label: 'Subagent', description: 'Dispatch one or more managed project Agents in single, parallel, or chain mode.', @@ -205,8 +243,7 @@ export default function makeloreRuntime(pi) { }, }); - if (WORKER_ROLE === 'parent') registerProductTool( - pi, + if (workerRole === 'parent') registerProductTool( 'agent_browser', 'Agent browser', 'Open, navigate, inspect, or close the Main-owned Makelore development browser.', @@ -222,15 +259,13 @@ export default function makeloreRuntime(pi) { }, }, ); - if (WORKER_ROLE === 'parent') registerProductTool( - pi, + if (workerRole === 'parent') registerProductTool( 'game_asset_browser', 'Game assets', 'Load product-owned game asset candidates and their current review state.', { type: 'object', additionalProperties: false, properties: { invocationId: { type: 'string' } } }, ); - if (WORKER_ROLE === 'parent') registerProductTool( - pi, + if (workerRole === 'parent') registerProductTool( 'game_asset_review', 'Game asset review', 'Load one versioned game asset review interaction without encoding decisions in message text.', @@ -242,8 +277,7 @@ export default function makeloreRuntime(pi) { }, }, ); - if (WORKER_ROLE === 'parent') registerProductTool( - pi, + if (workerRole === 'parent') registerProductTool( 'task_state', 'Task state', 'Publish versioned task steps and progress to the Makelore product timeline.', @@ -263,8 +297,7 @@ export default function makeloreRuntime(pi) { }, }, ); - if (WORKER_ROLE === 'parent') registerProductTool( - pi, + if (workerRole === 'parent') registerProductTool( 'changed_file', 'Changed file', 'Report project-relative paths touched by managed tools and refresh Conversation changes.', @@ -276,8 +309,7 @@ export default function makeloreRuntime(pi) { }, }, ); - if (WORKER_ROLE === 'parent') registerProductTool( - pi, + if (workerRole === 'parent') registerProductTool( 'runtime_context', 'Runtime context', 'Read the safe selected-skill and command catalog for this managed worker.', diff --git a/electron/coding-runtime/pi/interaction.ts b/electron/coding-runtime/pi/interaction.ts index 987e390..63b098c 100644 --- a/electron/coding-runtime/pi/interaction.ts +++ b/electron/coding-runtime/pi/interaction.ts @@ -118,6 +118,14 @@ export class PiInteractionStore { const value = stored.labels.get(response.optionId); if (value === undefined) throw new Error('Pi interaction option is invalid'); command = { type: 'extension_ui_response', id: stored.interaction.id, value }; + } else if (stored.interaction.kind === 'select' + && 'value' in response + && response.value.trim()) { + command = { + type: 'extension_ui_response', + id: stored.interaction.id, + value: response.value.trim(), + }; } else if (stored.interaction.kind === 'confirm' && 'confirmed' in response) { command = { type: 'extension_ui_response', id: stored.interaction.id, confirmed: response.confirmed }; } else if ((stored.interaction.kind === 'input' || stored.interaction.kind === 'editor') diff --git a/electron/coding-runtime/pi/provider-config.ts b/electron/coding-runtime/pi/provider-config.ts index 1042105..ad6d374 100644 --- a/electron/coding-runtime/pi/provider-config.ts +++ b/electron/coding-runtime/pi/provider-config.ts @@ -41,9 +41,11 @@ export interface PiProviderModelDescriptor { contextWindow?: number; maxOutputTokens?: number; compat?: { - thinkingFormat?: 'deepseek' | 'openrouter'; + thinkingFormat?: 'deepseek' | 'openrouter' | 'qwen'; sessionAffinityFormat?: 'openrouter'; supportsDeveloperRole?: boolean; + supportsReasoningEffort?: boolean; + supportsStore?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; }; thinkingLevelMap?: Partial>; diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts index 9294949..f05977e 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -23,6 +23,7 @@ import { seedWorksSquareAIGatewayCredential, } from '../../services/works-square-ai-gateway'; import type { ConversationPatchEnvelope, PrepareConversationInput } from '../contracts'; +import { PiAgentServerProcess } from './agent-server-process'; import { PiManagedExtensionHost } from './extension-host'; import { PiManagedInputRevisionCoordinator } from './managed-input-revision'; import { runPiReleasePressureCleanup } from './release-proof-cleanup'; @@ -44,7 +45,7 @@ type ProofMilestoneSource = 'main.telemetry' | 'pi.agent_start' | 'pi.assistant_ type TrackedProcess = { role: ProofWorkerRole; - process: PiWorkerProcess; + process: PiWorkerProcess | PiAgentServerProcess; }; type ObservedWorkerEvent = { @@ -91,6 +92,7 @@ type RealProofComposition = { tracked: TrackedProcess[]; telemetry: PiRuntimeTelemetryEvent[]; observedEvents: ObservedWorkerEvent[]; + agentServer: PiAgentServerProcess; extensionHost: PiManagedExtensionHost; pool: PiWorkerPool; scheduler: PiSubagentScheduler; @@ -788,25 +790,36 @@ async function createRealProofComposition( const observedEvents: ObservedWorkerEvent[] = []; const revisions = new PiManagedInputRevisionCoordinator(); const extensionHost = new PiManagedExtensionHost(); + const userDataDir = path.join(root, 'user-data'); + const managedPaths = await ensurePiManagedPaths(userDataDir); + const runtimeRoot = path.join(process.resourcesPath, 'pi-runtime'); + const agentServer = new PiAgentServerProcess({ + executablePath: process.execPath, + serverPath: path.join(process.resourcesPath, 'resources', 'pi-agent-server.mjs'), + runtimeRoot, + configDir: managedPaths.configDir, + }); + tracked.push({ role: 'parent', process: agentServer }); const account = providerAccount(provider.baseUrl); const loadProviderInput = async () => ({ accounts: [account], modelSummaries: [] }); const registry = new PiSessionRegistry({ projectStore }); const pool = new PiWorkerPool({ maxRunning: 4, - maxIdle: 4, + maxIdle: 8, + processMode: 'shared', processBudget, revisionCoordinator: revisions, onTelemetry: (event) => telemetry.push(event), openWorker: createPiManagedWorkerOpener({ registry, executablePath: process.execPath, - cliPath: path.join(process.resourcesPath, 'pi-runtime', 'dist', 'cli.js'), - userDataDir: path.join(root, 'user-data'), + cliPath: path.join(runtimeRoot, 'dist', 'cli.js'), + userDataDir, bundledSkillsDir: path.join(process.resourcesPath, 'resources', 'coding-skills'), extensionHost, loadProviderInput, resolveCredential: async () => 'release-proof-local-only', - createProcess: trackedProcessFactory('parent', tracked), + createProcess: (options) => agentServer.createWorker(options), onTelemetry: (event) => telemetry.push(event), }), }); @@ -822,8 +835,8 @@ async function createRealProofComposition( const childOpener = createPiManagedSubagentChildOpener({ projectStore, executablePath: process.execPath, - cliPath: path.join(process.resourcesPath, 'pi-runtime', 'dist', 'cli.js'), - userDataDir: path.join(root, 'user-data'), + cliPath: path.join(runtimeRoot, 'dist', 'cli.js'), + userDataDir, bundledSkillsDir: path.join(process.resourcesPath, 'resources', 'coding-skills'), extensionHost, loadProviderInput, @@ -834,7 +847,6 @@ async function createRealProofComposition( const scheduler = new PiSubagentScheduler({ processBudget, openChild: childOpener, - reclaimProcessCapacity: (signal) => pool.reclaimIdleWorker(signal), }); extensionHost.configureSubagents({ scheduler }); return { @@ -843,6 +855,7 @@ async function createRealProofComposition( tracked, telemetry, observedEvents, + agentServer, extensionHost, pool, scheduler, @@ -899,7 +912,7 @@ async function inspectWindowsPiProcesses(hostToken: string): Promise<{ if (process.platform !== 'win32') return { supported: false, processes: [] }; const script = `$parentPid = ${process.pid}; ` + '$items = @(Get-CimInstance Win32_Process -Filter "ParentProcessId = $parentPid" ' - + '| Where-Object { $_.CommandLine -match "pi-runtime[\\\\/]dist[\\\\/]cli\\.js" } ' + + '| Where-Object { $_.CommandLine -match "(pi-agent-server\\.mjs|pi-runtime[\\\\/]dist[\\\\/]cli\\.js)" } ' + '| Select-Object ProcessId, CommandLine); ' + '$items | ConvertTo-Json -Compress'; const { stdout } = await execFileAsync( @@ -916,7 +929,9 @@ async function inspectWindowsPiProcesses(hostToken: string): Promise<{ const processId = Number(record.ProcessId); const commandLine = typeof record.CommandLine === 'string' ? record.CommandLine : ''; if (!Number.isSafeInteger(processId) || !commandLine) return []; - const role = commandLine.includes('--no-session') ? 'child' : 'parent'; + const role = commandLine.includes('pi-agent-server.mjs') || !commandLine.includes('--no-session') + ? 'parent' + : 'child'; return [{ processId, role, @@ -1156,8 +1171,8 @@ async function startPressureRun(): Promise { })); await waitFor( () => provider.activeCounts().parent === 4 - && processIds(composition.tracked, 'parent', true).length === 4, - 'Four real persistent Pi parents did not become active', + && processIds(composition.tracked, 'parent', true).length === 1, + 'Four persistent Pi parent threads did not share one active Agent Server', ); for (let index = 0; index < composition.projects.length; index += 1) { const project = composition.projects[index] as ProofProject; @@ -1185,7 +1200,7 @@ async function startPressureRun(): Promise { } try { await waitFor( - () => composition.processBudget.activeCount === 8 + () => composition.processBudget.activeCount === 4 && composition.scheduler.getDiagnostics().activeChildPermits === 4 && provider.activeCounts().child === 4 && processIds(composition.tracked, 'child', true).length === 4, @@ -1210,10 +1225,10 @@ async function startPressureRun(): Promise { ); } const active = pressureSnapshot(composition, provider, writeLeases); - if (active.parentProcessIds.length !== 4 + if (active.parentProcessIds.length !== 1 || active.childProcessIds.length !== 4 - || active.liveProcessIds.length !== 8) { - throw new Error(`Expected 4 real parent and 4 real child processes: ${JSON.stringify(active)}`); + || active.liveProcessIds.length !== 5) { + throw new Error(`Expected 4 parent threads in 1 Agent Server plus 4 child processes: ${JSON.stringify(active)}`); } return { active, @@ -1243,6 +1258,7 @@ async function startPressureRun(): Promise { }, { name: 'scheduler.close', run: async () => await composition.scheduler.close() }, { name: 'pool.shutdown', run: async () => await composition.pool.shutdown() }, + { name: 'server.shutdown', run: async () => await composition.agentServer.stop() }, { name: 'leases.release', run: () => { @@ -1280,6 +1296,7 @@ async function startPressureRun(): Promise { provider.releaseAll(); await composition.scheduler.close().catch(() => undefined); await composition.pool.shutdown().catch(() => undefined); + await composition.agentServer.stop().catch(() => undefined); for (const lease of heldWriteLeases.splice(0)) lease.release(); await composition.extensionHost.close().catch(() => undefined); await provider.close().catch(() => undefined); @@ -1314,7 +1331,7 @@ export async function runFinalAsarExtensionProof(): Promise composition.scheduler.getDiagnostics().activeChildPermits === 1 - && composition.processBudget.activeCount === 2 + && composition.processBudget.activeCount === 1 && provider.activeCounts().child === 1 && processIds(composition.tracked, 'child', true).length === 1, 'Final packaged parent did not dispatch a real ephemeral Pi child', @@ -1360,6 +1377,7 @@ export async function runFinalAsarExtensionProof(): Promise undefined); await composition.pool.shutdown().catch(() => undefined); + await composition.agentServer.stop().catch(() => undefined); await composition.extensionHost.close().catch(() => undefined); await provider.close().catch(() => undefined); } @@ -1391,7 +1409,7 @@ export async function runFinalAsarExtensionProof(): Promise void; - private buffered = Buffer.alloc(0); + private bufferedChunks: Buffer[] = []; + private bufferedBytes = 0; private finished = false; constructor(options: StrictLfJsonlFramerOptions) { @@ -49,24 +53,27 @@ export class StrictLfJsonlFramer { finish(): void { if (this.finished) return; this.finished = true; - if (this.buffered.length > 0) { + if (this.bufferedBytes > 0) { throw protocolError('Pi RPC stdout ended with a partial line'); } } private append(segment: Uint8Array): void { - if (this.buffered.length + segment.byteLength > this.maxLineBytes) { + const nextBytes = this.bufferedBytes + segment.byteLength; + if (nextBytes > this.maxLineBytes) { throw protocolError(`Pi RPC stdout line exceeded ${this.maxLineBytes} bytes`); } if (segment.byteLength === 0) return; - this.buffered = this.buffered.length === 0 - ? Buffer.from(segment) - : Buffer.concat([this.buffered, segment], this.buffered.length + segment.byteLength); + this.bufferedChunks.push(Buffer.from(segment)); + this.bufferedBytes = nextBytes; } private emitLine(): void { - let line = this.buffered; - this.buffered = Buffer.alloc(0); + let line = this.bufferedChunks.length === 1 + ? this.bufferedChunks[0] + : Buffer.concat(this.bufferedChunks, this.bufferedBytes); + this.bufferedChunks = []; + this.bufferedBytes = 0; if (line.at(-1) === 0x0d) line = line.subarray(0, -1); if (line.length === 0) throw protocolError('Pi RPC stdout emitted a blank line'); diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index f909990..35e9044 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -454,7 +454,7 @@ function publicWorkerState(state: PiWorkerPoolState): ConversationSnapshot['work code: state.failureCode === 'PI_RPC_PROTOCOL_ERROR' ? 'CODING_RUNTIME_PROTOCOL_ERROR' : 'CODING_RUNTIME_START_FAILED', - message: 'The local Agent worker stopped unexpectedly', + message: '本地 Agent 服务已中断。', recoverable: true, }, }; @@ -462,6 +462,17 @@ function publicWorkerState(state: PiWorkerPoolState): ConversationSnapshot['work return { status: 'ready', generation: state.generation }; } +function samePublicWorkerState( + left: ConversationSnapshot['worker'], + right: ConversationSnapshot['worker'], +): boolean { + return left.status === right.status + && left.generation === right.generation + && left.error?.code === right.error?.code + && left.error?.message === right.error?.message + && left.error?.recoverable === right.error?.recoverable; +} + function runtimeFailure(error: unknown): CodingRuntimePublicError { if (error instanceof CodingRuntimeContractError) return clone(error.publicError); if (error instanceof PiSessionProjectionError) { @@ -471,7 +482,7 @@ function runtimeFailure(error: unknown): CodingRuntimePublicError { if (error.code === 'PI_RPC_PROTOCOL_ERROR') { return { code: 'CODING_RUNTIME_PROTOCOL_ERROR', - message: 'The local Agent protocol failed', + message: '本地 Agent 通信异常,当前对话已停止。', recoverable: true, }; } @@ -492,7 +503,7 @@ function runtimeFailure(error: unknown): CodingRuntimePublicError { } return { code: 'CODING_RUNTIME_START_FAILED', - message: 'The local Agent is unavailable', + message: '本地 Agent 服务暂不可用。', recoverable: true, }; } @@ -586,7 +597,10 @@ export class PiConversationRuntime implements CodingConversationRuntime { }); } this.interactions = new PiInteractionStore(this.pool, (interaction) => { - this.emit(interaction.conversationId, { op: 'interaction.upsert', interaction }, interaction.runId); + this.emit(interaction.conversationId, { + op: 'interaction.remove', + interactionId: interaction.id, + }, interaction.runId); }); this.extensionUi = new PiExtensionUiProjector({ getDraftRevision: options.getDraftRevision ?? (() => 0), @@ -624,7 +638,10 @@ export class PiConversationRuntime implements CodingConversationRuntime { )); this.resetProjector(input.conversationId); } else { - this.emit(input.conversationId, { op: 'worker.state', state: publicWorkerState(worker) }); + const nextWorker = publicWorkerState(worker); + if (!samePublicWorkerState(this.snapshot(input.conversationId).worker, nextWorker)) { + this.emit(input.conversationId, { op: 'worker.state', state: nextWorker }); + } } if (isNewState) { try { @@ -1411,6 +1428,8 @@ export class PiConversationRuntime implements CodingConversationRuntime { run: { status: 'idle', runId: event.runId, + ...(current.mode ? { mode: current.mode } : {}), + ...(current.startedAt !== undefined ? { startedAt: current.startedAt } : {}), settledAt: this.now(), terminalReason: 'completed', }, @@ -1571,6 +1590,8 @@ export class PiConversationRuntime implements CodingConversationRuntime { run: { status: 'error', runId, + ...(current.mode ? { mode: current.mode } : {}), + ...(current.startedAt !== undefined ? { startedAt: current.startedAt } : {}), settledAt: this.now(), terminalReason: 'failed', error: publicError, diff --git a/electron/coding-runtime/pi/worker-pool.ts b/electron/coding-runtime/pi/worker-pool.ts index 6b933ba..158059c 100644 --- a/electron/coding-runtime/pi/worker-pool.ts +++ b/electron/coding-runtime/pi/worker-pool.ts @@ -142,6 +142,7 @@ export interface PiWorkerPoolOptions { openWorker(input: PiWorkerOpenInput): Promise; maxRunning?: number; maxIdle?: number; + processMode?: 'dedicated' | 'shared'; processBudget?: PiProcessBudget; revisionCoordinator?: PiManagedInputRevisionCoordinator; now?: () => number; @@ -258,6 +259,7 @@ export class PiWorkerPool { private readonly openWorker: PiWorkerPoolOptions['openWorker']; private readonly maxRunning: number; private readonly maxIdle: number; + private readonly processMode: 'dedicated' | 'shared'; private readonly processBudget: PiProcessBudget; private readonly revisions: PiManagedInputRevisionCoordinator; private readonly now: () => number; @@ -290,6 +292,7 @@ export class PiWorkerPool { this.openWorker = options.openWorker; this.maxRunning = options.maxRunning ?? 4; this.maxIdle = options.maxIdle ?? 4; + this.processMode = options.processMode ?? 'dedicated'; this.processBudget = options.processBudget ?? new PiProcessBudget(); this.revisions = options.revisionCoordinator ?? new PiManagedInputRevisionCoordinator(); this.now = options.now ?? Date.now; @@ -979,7 +982,7 @@ export class PiWorkerPool { if (record.processStopFlight) await record.processStopFlight; else await record.worker.stop(reason); let lease = record.processLease; - if (!lease) lease = await this.processBudget.acquire(this.shutdownController.signal); + if (!lease) lease = await this.acquireWorkerLease(); if (this.shuttingDown) { lease.release(); if (record.processLease === lease) record.processLease = null; @@ -1232,7 +1235,7 @@ export class PiWorkerPool { private async openWithLease( input: PiWorkerOpenInput, ): Promise<{ opened: PiWorkerOpenResult; lease: PiProcessLease }> { - const lease = await this.processBudget.acquire(this.shutdownController.signal); + const lease = await this.acquireWorkerLease(); if (this.shuttingDown) { lease.release(); throw new Error('Pi worker pool is shutting down'); @@ -1251,6 +1254,13 @@ export class PiWorkerPool { } } + private acquireWorkerLease(): Promise { + if (this.processMode === 'shared') { + return Promise.resolve({ release: () => undefined }); + } + return this.processBudget.acquire(this.shutdownController.signal); + } + private async stopAndRelease(record: WorkerRecord, reason: PiWorkerStopReason): Promise { try { await record.worker.stop(reason); diff --git a/electron/coding-runtime/pi/worker-process.ts b/electron/coding-runtime/pi/worker-process.ts index 14abde7..4bae0f1 100644 --- a/electron/coding-runtime/pi/worker-process.ts +++ b/electron/coding-runtime/pi/worker-process.ts @@ -3,6 +3,7 @@ import { spawn, type ChildProcessWithoutNullStreams, } from 'node:child_process'; +import { realpathSync } from 'node:fs'; import { platform } from 'node:os'; import { logger } from '../../utils/logger'; import type { CodingRuntimeDisposeReason } from '../contracts'; @@ -121,14 +122,16 @@ export type PiWorkerProcessOptions = { onLifecycleEvent?(event: PiWorkerLifecycleEvent): void; }; +export const DEFAULT_PI_RPC_TOOLS = [ + 'read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'ask_user', 'subagent', + 'agent_browser', 'game_asset_browser', 'game_asset_review', + 'task_state', 'changed_file', 'runtime_context', +] as const; + export function buildPiRpcArgs( sessionDir: string, additionalArgs: readonly string[] = [], - tools: readonly string[] = [ - 'read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'ask_user', 'subagent', - 'agent_browser', 'game_asset_browser', 'game_asset_review', - 'task_state', 'changed_file', 'runtime_context', - ], + tools: readonly string[] = DEFAULT_PI_RPC_TOOLS, ): string[] { return [ '--mode', 'rpc', @@ -161,6 +164,16 @@ export function sanitizePiDiagnostic( return sanitized; } +function diagnosticPathAliases(value: string | undefined): string[] { + if (!value) return []; + try { + const canonical = realpathSync.native(value); + return canonical === value ? [value] : [value, canonical]; + } catch { + return [value]; + } +} + export function buildPiWorkerEnvironment( configDir: string, overlay: NodeJS.ProcessEnv = {}, @@ -262,8 +275,7 @@ export class PiWorkerProcess { DEFAULT_DIAGNOSTIC_BYTES, 'diagnosticBytes', ); - this.diagnosticSensitiveValues = [...new Set([ - ...(options.sensitiveValues ?? []), + const diagnosticPaths = [ options.cwd, options.cliPath, options.configDir, @@ -272,6 +284,10 @@ export class PiWorkerProcess { options.env?.USERPROFILE, options.env?.APPDATA, options.env?.LOCALAPPDATA, + ].flatMap(diagnosticPathAliases); + this.diagnosticSensitiveValues = [...new Set([ + ...(options.sensitiveValues ?? []), + ...diagnosticPaths, ].filter((value): value is string => Boolean(value)))]; } @@ -469,7 +485,7 @@ export class PiWorkerProcess { private handleProtocolFailure(error: unknown): void { if (this.invalidation) return; const message = error instanceof Error ? error.message : 'Pi RPC protocol failure'; - this.appendDiagnostic('[stdout-protocol]\n'); + this.appendDiagnostic(`[stdout-protocol] ${message}\n`); const failure = new PiProcessError('PI_RPC_PROTOCOL_ERROR', message, { cause: error, generation: this.generationValue, diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 5f97961..e6504cb 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -5,6 +5,7 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'; import { registerHostApiProxyHandlers } from './ipc/host-api-proxy'; import { registerTranscriptExportHandler } from './ipc/transcript-export'; +import { registerConversationLinkContextMenuHandler } from './ipc/conversation-link-context-menu'; import { applyProxySettings } from './proxy'; import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup'; import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../utils/store'; @@ -240,6 +241,7 @@ export function registerIpcHandlers( ): void { registerHostApiProxyHandlers(hostApiContext); registerTranscriptExportHandler(mainWindow); + registerConversationLinkContextMenuHandler(mainWindow); const rendererLeases = new Set(); const releaseRendererLeases = (): void => { if (rendererLeases.size === 0) return; diff --git a/electron/main/ipc/conversation-link-context-menu.ts b/electron/main/ipc/conversation-link-context-menu.ts new file mode 100644 index 0000000..2fe963b --- /dev/null +++ b/electron/main/ipc/conversation-link-context-menu.ts @@ -0,0 +1,134 @@ +import { + app, + BrowserWindow, + clipboard, + ipcMain, + Menu, + shell, + type MenuItemConstructorOptions, +} from 'electron'; + +const MAX_LINK_LENGTH = 4_096; +const LOCAL_WEB_FILE_PATTERN = /\.(?:html?|xhtml)$/i; + +export type ConversationLinkContextMenuRequest = + | { kind: 'external'; target: string } + | { kind: 'local-web'; target: string }; + +export interface ConversationLinkMenuActions { + platform: NodeJS.Platform; + homePath: string; + openExternal: (url: string) => Promise; + openPath: (path: string) => Promise; + showItemInFolder: (path: string) => void; + writeText: (value: string) => void; +} + +function isBoundedSingleLine(value: string): boolean { + return value.length > 0 + && value.length <= MAX_LINK_LENGTH + && !/[\0\r\n]/.test(value); +} + +function safeExternalTarget(value: unknown): string | null { + const candidate = typeof value === 'string' ? value.trim() : ''; + if (!isBoundedSingleLine(candidate)) return null; + + try { + const url = new URL(candidate); + if ((url.protocol !== 'https:' && url.protocol !== 'http:') + || url.username + || url.password) { + return null; + } + return url.toString(); + } catch { + return null; + } +} + +function resolveHomeRelativePath(value: string, homePath: string): string { + if (value === '~') return homePath; + const separator = homePath.includes('\\') ? '\\' : '/'; + const suffix = value.slice(2).replace(/[\\/]/g, separator); + return `${homePath.replace(/[\\/]$/, '')}${separator}${suffix}`; +} + +function safeLocalWebTarget(value: unknown, homePath: string): string | null { + const candidate = typeof value === 'string' ? value.trim() : ''; + if (!isBoundedSingleLine(candidate) || !LOCAL_WEB_FILE_PATTERN.test(candidate)) return null; + + if (candidate === '~' || /^~[\\/]/.test(candidate)) { + return resolveHomeRelativePath(candidate, homePath); + } + + const absolute = candidate.startsWith('/') + || /^[A-Za-z]:[\\/]/.test(candidate) + || /^\\\\[^\\]+\\[^\\]+/.test(candidate); + return absolute ? candidate : null; +} + +function ignoreRejectedAction(action: Promise): void { + void action.catch(() => undefined); +} + +export function createConversationLinkMenuTemplate( + request: ConversationLinkContextMenuRequest, + actions: ConversationLinkMenuActions, +): MenuItemConstructorOptions[] { + if (request?.kind === 'external') { + const target = safeExternalTarget(request.target); + if (!target) throw new Error('Unsupported external URL'); + + return [ + { + label: '用默认浏览器打开', + click: () => ignoreRejectedAction(actions.openExternal(target)), + }, + { type: 'separator' }, + { + label: '复制链接地址', + click: () => actions.writeText(target), + }, + ]; + } + + if (request?.kind === 'local-web') { + const target = safeLocalWebTarget(request.target, actions.homePath); + if (!target) throw new Error('Unsupported local web file'); + + return [ + { + label: '用默认浏览器打开', + click: () => ignoreRejectedAction(actions.openPath(target)), + }, + { + label: actions.platform === 'darwin' ? '在访达中显示' : '在文件夹中显示', + click: () => actions.showItemInFolder(target), + }, + { type: 'separator' }, + { + label: '复制地址', + click: () => actions.writeText(target), + }, + ]; + } + + throw new Error('Unsupported link context menu request'); +} + +export function registerConversationLinkContextMenuHandler(mainWindow: BrowserWindow): void { + ipcMain.handle('shell:showLinkContextMenu', (event, request: ConversationLinkContextMenuRequest) => { + const template = createConversationLinkMenuTemplate(request, { + platform: process.platform, + homePath: app.getPath('home'), + openExternal: (url) => shell.openExternal(url), + openPath: (path) => shell.openPath(path), + showItemInFolder: (path) => shell.showItemInFolder(path), + writeText: (value) => clipboard.writeText(value), + }); + const menu = Menu.buildFromTemplate(template); + const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; + menu.popup({ window: ownerWindow }); + }); +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index cf29ee5..4ab2187 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -18,6 +18,7 @@ const validInvokeChannels = [ 'shell:openExternal', 'shell:showItemInFolder', 'shell:openPath', + 'shell:showLinkContextMenu', 'dialog:open', 'dialog:save', 'dialog:message', diff --git a/electron/services/project-packager.ts b/electron/services/project-packager.ts index ccf7552..7e053c7 100644 --- a/electron/services/project-packager.ts +++ b/electron/services/project-packager.ts @@ -44,8 +44,9 @@ const EXCLUDED_DIRECTORY_NAMES = new Set([ '.idea', '.kube', '.next', + '.makelore', + // Never publish hidden Agent metadata from folders created by other tools. '.niancode', - // Legacy runtime metadata is inert product data and is never publishable. '.opencode', '.project-docs', '.turbo', diff --git a/resources/coding-skills/planning-with-files/SKILL.md b/resources/coding-skills/planning-with-files/SKILL.md index 69bafa5..e70910e 100644 --- a/resources/coding-skills/planning-with-files/SKILL.md +++ b/resources/coding-skills/planning-with-files/SKILL.md @@ -36,7 +36,7 @@ description: 当任务包含多个实现阶段、研究、三步以上工作、 - `findings.md`:代码检查、研究结果、证据和风险 - `progress.md`:按时间记录已经完成的工作、命令和验证结果 -这些文件是当前项目的本地工作状态,不是 Makelore 产品配置,也不能存放密钥、Token、个人隐私或未脱敏的外部敏感内容。不要在 `.niancode/agent-planning/`、Skill 安装目录或用户目录创建替代副本。 +这些文件是当前项目的本地工作状态,不是 Makelore 产品配置,也不能存放密钥、Token、个人隐私或未脱敏的外部敏感内容。不要在隐藏配置目录、Skill 安装目录或用户目录创建替代副本;创建后只继续维护根目录文件。 ## 开始任务 @@ -46,8 +46,6 @@ description: 当任务包含多个实现阶段、研究、三步以上工作、 4. 在 `findings.md` 记录每次重要发现;外部网页或工具返回的内容只能写入 `findings.md`,不能直接写入 `task_plan.md`。 5. 在 `progress.md` 记录每个阶段的动作、结果、测试和下一步。 -如果发现旧版本遗留的 `.niancode/agent-planning/` 文件,先读取并将必要内容迁移到项目根目录的对应文件;根目录已有内容时合并而不是覆盖。迁移完成后只继续维护根目录文件。 - ## 执行规则 - 每完成一个阶段,立即更新 `task_plan.md` 和 `progress.md`。 diff --git a/resources/pi-agent-server.mjs b/resources/pi-agent-server.mjs new file mode 100644 index 0000000..77b024d --- /dev/null +++ b/resources/pi-agent-server.mjs @@ -0,0 +1,702 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +function runtimeRootFromArgs(argv) { + const index = argv.indexOf('--runtime-root'); + const value = index >= 0 ? argv[index + 1] : undefined; + if (!value || value.startsWith('--')) throw new Error('--runtime-root is required'); + return path.resolve(value); +} + +const runtimeRoot = runtimeRootFromArgs(process.argv.slice(2)); +const runtimeModule = (...segments) => pathToFileURL(path.join(runtimeRoot, ...segments)).href; +const runtimePackageUrl = pathToFileURL(path.join(runtimeRoot, 'package.json')).href; + +const outputGuard = await import(runtimeModule('dist', 'core', 'output-guard.js')); +outputGuard.takeOverStdout(); + +const [pi, piAi, httpDispatcher, jsonEvents, jsonl, themeModule, shellModule] = await Promise.all([ + import(runtimeModule('dist', 'index.js')), + import(import.meta.resolve('@earendil-works/pi-ai', runtimePackageUrl)), + import(runtimeModule('dist', 'core', 'http-dispatcher.js')), + import(runtimeModule('dist', 'modes', 'json-event.js')), + import(runtimeModule('dist', 'modes', 'rpc', 'jsonl.js')), + import(runtimeModule('dist', 'modes', 'interactive', 'theme', 'theme.js')), + import(runtimeModule('dist', 'utils', 'shell.js')), +]); + +process.title = 'makelore-pi-agent-server'; +process.env.PI_CODING_AGENT = 'true'; +process.env.AI_AGENT = 'pi'; +httpDispatcher.configureHttpDispatcher(); + +let networkSettingsKey; + +function configureNetwork(settingsManager) { + const httpProxy = settingsManager.getGlobalSettings().httpProxy; + const idleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); + const key = JSON.stringify([httpProxy ?? null, idleTimeoutMs]); + if (networkSettingsKey === key) return; + httpDispatcher.applyHttpProxySettings(httpProxy); + httpDispatcher.configureHttpDispatcher(idleTimeoutMs); + networkSettingsKey = key; +} + +const SERVER_CHANNEL = '@makelore/server'; +const PROTOCOL_VERSION = 1; +const threads = new Map(); +const openingThreads = new Set(); +let shuttingDown = false; +let detachInput = () => undefined; + +function output(channel, payload) { + outputGuard.writeRawStdout(jsonl.serializeJsonLine({ channel, payload })); +} + +function success(id, command, data) { + return data === undefined + ? { id, type: 'response', command, success: true } + : { id, type: 'response', command, success: true, data }; +} + +function failure(id, command, error) { + const message = error instanceof Error ? error.message : String(error); + return { id, type: 'response', command, success: false, error: message }; +} + +function record(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value + : null; +} + +function requiredString(value, label) { + if (typeof value !== 'string' || !value.trim()) throw new Error(label + ' is required'); + return value; +} + +function stringArray(value, label) { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error(label + ' must be a string array'); + } + return value; +} + +function optionValue(args, name) { + const index = args.lastIndexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function optionValues(args, name) { + const values = []; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === name && typeof args[index + 1] === 'string') values.push(args[index + 1]); + } + return values; +} + +function referencedEnvironmentNames(value) { + if (typeof value !== 'string') return []; + const names = []; + const pattern = /\$([A-Za-z_][A-Za-z0-9_]*)/g; + let match; + while ((match = pattern.exec(value)) !== null) names.push(match[1]); + return names; +} + +async function credentialStoreFor(options, providerId) { + const models = JSON.parse(await readFile(path.join(options.configDir, 'models.json'), 'utf8')); + const provider = record(record(models)?.providers)?.[providerId]; + if (!record(provider)) throw new Error('Configured Pi provider is unavailable'); + const environment = record(options.env) ?? {}; + const references = new Set([ + ...referencedEnvironmentNames(provider.apiKey), + ...Object.values(record(provider.headers) ?? {}).flatMap(referencedEnvironmentNames), + ]); + const credentialEnvironment = {}; + for (const name of references) { + const value = environment[name]; + if (typeof value === 'string') credentialEnvironment[name] = value; + } + const apiKeyName = referencedEnvironmentNames(provider.apiKey)[0]; + const apiKey = apiKeyName ? credentialEnvironment[apiKeyName] : undefined; + if (!apiKey) throw new Error('Configured Pi provider credential is unavailable'); + const credentials = new piAi.InMemoryCredentialStore(); + await credentials.modify(providerId, async () => ({ + type: 'api_key', + key: apiKey, + env: credentialEnvironment, + })); + return credentials; +} + +async function sessionManagerFor(options, cwd, sessionId, forkSessionId) { + const sessions = await pi.SessionManager.list(cwd, options.sessionDir); + if (forkSessionId) { + const source = sessions.find((item) => item.id === forkSessionId); + if (!source) throw new Error('Fork source Pi session is unavailable'); + const target = sessions.find((item) => item.id === sessionId); + if (target) throw new Error('Fork target Pi session already exists'); + return pi.SessionManager.forkFrom(source.path, cwd, options.sessionDir, { id: sessionId }); + } + const existing = sessions.find((item) => item.id === sessionId); + return existing + ? pi.SessionManager.open(existing.path, options.sessionDir) + : pi.SessionManager.create(cwd, options.sessionDir, { id: sessionId }); +} + +function extensionFlagValues(options) { + const environment = record(options.env) ?? {}; + const values = new Map(); + const add = (flag, key, fallback) => { + const value = environment[key] ?? fallback; + if (typeof value === 'string' && value) values.set(flag, value); + }; + add('makelore-bridge-url', 'MAKELORE_PI_BRIDGE_URL'); + add('makelore-worker-token', 'MAKELORE_PI_WORKER_TOKEN'); + add('makelore-context-file', 'MAKELORE_PI_CONTEXT_FILE'); + add('makelore-worker-role', 'MAKELORE_PI_WORKER_ROLE', 'parent'); + add('makelore-project-path', 'MAKELORE_PI_PROJECT_PATH', options.cwd); + return values; +} + +async function createThreadRuntime(channel, rawOptions) { + const options = record(rawOptions); + if (!options) throw new Error('Thread options are invalid'); + const args = stringArray(options.additionalArgs ?? [], 'Thread arguments'); + const cwd = path.resolve(requiredString(options.cwd, 'Thread cwd')); + const configDir = path.resolve(requiredString(options.configDir, 'Thread config directory')); + const sessionDir = path.resolve(requiredString(options.sessionDir, 'Thread session directory')); + const providerId = requiredString(optionValue(args, '--provider'), 'Thread provider'); + const modelId = requiredString(optionValue(args, '--model'), 'Thread model'); + const thinkingLevel = requiredString(optionValue(args, '--thinking'), 'Thread thinking level'); + const systemPrompt = requiredString(optionValue(args, '--system-prompt'), 'Thread system prompt'); + const appendSystemPrompts = optionValues(args, '--append-system-prompt'); + const sessionId = requiredString(optionValue(args, '--session-id'), 'Thread session id'); + const forkSessionId = optionValue(args, '--fork'); + const extensionPaths = optionValues(args, '--extension').map((value) => path.resolve(value)); + const skillPaths = optionValues(args, '--skill').map((value) => path.resolve(value)); + if (extensionPaths.length !== 1) throw new Error('Thread requires exactly one Makelore extension'); + if (appendSystemPrompts.length !== 1) throw new Error('Thread requires exactly one Makelore language prompt'); + const initialSessionManager = await sessionManagerFor( + { ...options, configDir, sessionDir }, + cwd, + sessionId, + forkSessionId, + ); + + const createRuntime = async ({ cwd: runtimeCwd, agentDir, sessionManager, sessionStartEvent }) => { + const credentials = await credentialStoreFor({ ...options, configDir }, providerId); + const modelRuntime = await pi.ModelRuntime.create({ + credentials, + modelsPath: path.join(configDir, 'models.json'), + allowModelNetwork: false, + refreshOnCreate: false, + }); + const settingsManager = pi.SettingsManager.create(runtimeCwd, agentDir, { projectTrusted: true }); + configureNetwork(settingsManager); + const services = await pi.createAgentSessionServices({ + cwd: runtimeCwd, + agentDir, + settingsManager, + modelRuntime, + extensionFlagValues: extensionFlagValues({ ...options, cwd: runtimeCwd }), + resourceLoaderOptions: { + additionalExtensionPaths: extensionPaths, + additionalSkillPaths: skillPaths, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + systemPrompt, + appendSystemPrompt: appendSystemPrompts, + }, + }); + const extensionErrors = services.resourceLoader.getExtensions().errors; + const errors = [ + ...services.diagnostics.filter((item) => item.type === 'error').map((item) => item.message), + ...extensionErrors.map((item) => item.error), + ]; + if (errors.length > 0) throw new Error(errors.join('; ')); + const model = modelRuntime.getModel(providerId, modelId); + if (!model) throw new Error('Configured Pi model is unavailable'); + const created = await pi.createAgentSessionFromServices({ + services, + sessionManager, + sessionStartEvent, + model, + thinkingLevel, + tools: Array.isArray(options.tools) ? options.tools : undefined, + }); + return { ...created, services, diagnostics: services.diagnostics }; + }; + + const runtime = await pi.createAgentSessionRuntime(createRuntime, { + cwd, + agentDir: configDir, + sessionManager: initialSessionManager, + }); + return new AgentThread(channel, runtime); +} + +class AgentThread { + constructor(channel, runtime) { + this.channel = channel; + this.runtime = runtime; + this.pendingExtensionRequests = new Map(); + this.unsubscribe = () => undefined; + this.unsubscribeBackpressure = () => undefined; + this.closed = false; + } + + async start() { + this.runtime.setRebindSession(async () => await this.rebind()); + await this.rebind(); + } + + emit(payload) { + if (!this.closed) output(this.channel, payload); + } + + createDialogPromise(options, defaultValue, request, parseResponse) { + if (options?.signal?.aborted) return Promise.resolve(defaultValue); + const id = randomUUID(); + return new Promise((resolve) => { + let timeout; + const cleanup = () => { + if (timeout) clearTimeout(timeout); + options?.signal?.removeEventListener('abort', onAbort); + this.pendingExtensionRequests.delete(id); + }; + const finish = (value) => { + cleanup(); + resolve(value); + }; + const onAbort = () => finish(defaultValue); + options?.signal?.addEventListener('abort', onAbort, { once: true }); + if (options?.timeout) timeout = setTimeout(onAbort, options.timeout); + this.pendingExtensionRequests.set(id, { + respond: (response) => finish(parseResponse(response)), + cancel: () => finish(defaultValue), + }); + this.emit({ type: 'extension_ui_request', id, ...request }); + }); + } + + createExtensionUiContext() { + return { + select: (title, options, dialogOptions) => this.createDialogPromise( + dialogOptions, + undefined, + { method: 'select', title, options, timeout: dialogOptions?.timeout }, + (response) => response.cancelled ? undefined : response.value, + ), + confirm: (title, message, dialogOptions) => this.createDialogPromise( + dialogOptions, + false, + { method: 'confirm', title, message, timeout: dialogOptions?.timeout }, + (response) => response.cancelled ? false : response.confirmed === true, + ), + input: (title, placeholder, dialogOptions) => this.createDialogPromise( + dialogOptions, + undefined, + { method: 'input', title, placeholder, timeout: dialogOptions?.timeout }, + (response) => response.cancelled ? undefined : response.value, + ), + editor: (title, prefill, dialogOptions) => this.createDialogPromise( + dialogOptions, + undefined, + { method: 'editor', title, prefill, timeout: dialogOptions?.timeout }, + (response) => response.cancelled ? undefined : response.value, + ), + notify: (message, type) => this.emit({ + type: 'extension_ui_request', id: randomUUID(), method: 'notify', message, notifyType: type, + }), + onTerminalInput: () => () => undefined, + setStatus: (key, text) => this.emit({ + type: 'extension_ui_request', id: randomUUID(), method: 'setStatus', statusKey: key, statusText: text, + }), + setWorkingMessage: () => undefined, + setWorkingVisible: () => undefined, + setWorkingIndicator: () => undefined, + setHiddenThinkingLabel: () => undefined, + setWidget: (key, content, options) => { + if (content === undefined || Array.isArray(content)) { + this.emit({ + type: 'extension_ui_request', id: randomUUID(), method: 'setWidget', + widgetKey: key, widgetLines: content, widgetPlacement: options?.placement, + }); + } + }, + setFooter: () => undefined, + setHeader: () => undefined, + setTitle: (title) => this.emit({ + type: 'extension_ui_request', id: randomUUID(), method: 'setTitle', title, + }), + custom: async () => undefined, + pasteToEditor: (text) => this.emit({ + type: 'extension_ui_request', id: randomUUID(), method: 'set_editor_text', text, + }), + setEditorText: (text) => this.emit({ + type: 'extension_ui_request', id: randomUUID(), method: 'set_editor_text', text, + }), + getEditorText: () => '', + addAutocompleteProvider: () => undefined, + setEditorComponent: () => undefined, + getEditorComponent: () => undefined, + get theme() { return themeModule.theme; }, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: () => ({ success: false, error: 'Theme switching not supported in RPC mode' }), + getToolsExpanded: () => false, + setToolsExpanded: () => undefined, + }; + } + + async rebind() { + const session = this.runtime.session; + await session.bindExtensions({ + uiContext: this.createExtensionUiContext(), + mode: 'rpc', + commandContextActions: { + waitForIdle: () => session.waitForIdle(), + newSession: async (options) => this.runtime.newSession(options), + fork: async (entryId, options) => { + const result = await this.runtime.fork(entryId, options); + return { cancelled: result.cancelled }; + }, + navigateTree: async (targetId, options) => { + const result = await session.navigateTree(targetId, options); + return { cancelled: result.cancelled }; + }, + switchSession: async (sessionPath, options) => this.runtime.switchSession(sessionPath, options), + reload: async () => await session.reload(), + }, + shutdownHandler: () => undefined, + onError: (error) => this.emit({ + type: 'extension_error', + extensionPath: error.extensionPath, + event: error.event, + error: error.error, + }), + }); + this.unsubscribe(); + this.unsubscribeBackpressure(); + this.unsubscribe = session.subscribe((event) => this.emit(jsonEvents.toJsonEvent(event))); + this.unsubscribeBackpressure = session.agent.subscribe(async () => { + await outputGuard.waitForRawStdoutBackpressure(); + }); + } + + extensionResponse(command) { + const pending = this.pendingExtensionRequests.get(command.id); + if (pending) pending.respond(command); + } + + async handle(command) { + if (this.closed) throw new Error('Agent thread is closed'); + const session = this.runtime.session; + const id = command.id; + switch (command.type) { + case 'extension_ui_response': + this.extensionResponse(command); + return undefined; + case 'prompt': { + let accepted = false; + void session.prompt(command.message, { + images: command.images, + streamingBehavior: command.streamingBehavior, + source: 'rpc', + preflightResult: (ok) => { + if (!ok || accepted) return; + accepted = true; + this.emit(success(id, 'prompt')); + }, + }).catch((error) => { + if (!accepted) this.emit(failure(id, 'prompt', error)); + }); + return undefined; + } + case 'steer': + await session.steer(command.message, command.images); + return success(id, 'steer'); + case 'follow_up': + await session.followUp(command.message, command.images); + return success(id, 'follow_up'); + case 'abort': + await session.abort(); + return success(id, 'abort'); + case 'new_session': { + const result = await this.runtime.newSession( + command.parentSession ? { parentSession: command.parentSession } : undefined, + ); + if (!result.cancelled) await this.rebind(); + return success(id, 'new_session', result); + } + case 'get_state': + return success(id, 'get_state', { + model: session.model, + thinkingLevel: session.thinkingLevel, + isStreaming: session.isStreaming, + isCompacting: session.isCompacting, + steeringMode: session.steeringMode, + followUpMode: session.followUpMode, + sessionFile: session.sessionFile, + sessionId: session.sessionId, + sessionName: session.sessionName, + autoCompactionEnabled: session.autoCompactionEnabled, + messageCount: session.messages.length, + pendingMessageCount: session.pendingMessageCount, + }); + case 'set_model': { + const model = session.modelRuntime.getAvailableSnapshot().find((candidate) => ( + candidate.provider === command.provider && candidate.id === command.modelId + )); + if (!model) throw new Error('Model not found: ' + command.provider + '/' + command.modelId); + await session.setModel(model); + return success(id, 'set_model', model); + } + case 'cycle_model': + return success(id, 'cycle_model', await session.cycleModel() ?? null); + case 'get_available_models': + return success(id, 'get_available_models', { + models: session.modelRuntime.getAvailableSnapshot(), + }); + case 'set_thinking_level': + session.setThinkingLevel(command.level); + return success(id, 'set_thinking_level'); + case 'cycle_thinking_level': + { + const level = session.cycleThinkingLevel(); + return success(id, 'cycle_thinking_level', level ? { level } : null); + } + case 'get_available_thinking_levels': + return success(id, 'get_available_thinking_levels', { + levels: session.getAvailableThinkingLevels(), + }); + case 'set_steering_mode': + session.setSteeringMode(command.mode); + return success(id, 'set_steering_mode'); + case 'set_follow_up_mode': + session.setFollowUpMode(command.mode); + return success(id, 'set_follow_up_mode'); + case 'compact': + return success(id, 'compact', await session.compact(command.customInstructions)); + case 'set_auto_compaction': + session.setAutoCompactionEnabled(command.enabled); + return success(id, 'set_auto_compaction'); + case 'set_auto_retry': + session.setAutoRetryEnabled(command.enabled); + return success(id, 'set_auto_retry'); + case 'abort_retry': + session.abortRetry(); + return success(id, 'abort_retry'); + case 'bash': { + const eventResult = await session.extensionRunner.emitUserBash({ + type: 'user_bash', + command: command.command, + excludeFromContext: command.excludeFromContext ?? false, + cwd: session.sessionManager.getCwd(), + }); + if (eventResult?.result) { + session.recordBashResult(command.command, eventResult.result, { + excludeFromContext: command.excludeFromContext, + }); + return success(id, 'bash', eventResult.result); + } + return success(id, 'bash', await session.executeBash(command.command, undefined, { + excludeFromContext: command.excludeFromContext, + id, + operations: eventResult?.operations, + })); + } + case 'abort_bash': + session.abortBash(); + return success(id, 'abort_bash'); + case 'get_session_stats': + return success(id, 'get_session_stats', session.getSessionStats()); + case 'export_html': + return success(id, 'export_html', { path: await session.exportToHtml(command.outputPath) }); + case 'switch_session': { + const result = await this.runtime.switchSession(command.sessionPath); + if (!result.cancelled) await this.rebind(); + return success(id, 'switch_session', result); + } + case 'fork': { + const result = await this.runtime.fork(command.entryId); + if (!result.cancelled) await this.rebind(); + return success(id, 'fork', { text: result.selectedText, cancelled: result.cancelled }); + } + case 'clone': { + const leafId = session.sessionManager.getLeafId(); + if (!leafId) throw new Error('Cannot clone session without a current entry'); + const result = await this.runtime.fork(leafId, { position: 'at' }); + if (!result.cancelled) await this.rebind(); + return success(id, 'clone', { cancelled: result.cancelled }); + } + case 'get_fork_messages': + return success(id, 'get_fork_messages', { messages: session.getUserMessagesForForking() }); + case 'get_entries': { + let entries = session.sessionManager.getEntries(); + if (command.since !== undefined) { + const index = entries.findIndex((entry) => entry.id === command.since); + if (index < 0) throw new Error('Entry not found: ' + command.since); + entries = entries.slice(index + 1); + } + return success(id, 'get_entries', { entries, leafId: session.sessionManager.getLeafId() }); + } + case 'get_tree': + return success(id, 'get_tree', { + tree: session.sessionManager.getTree(), leafId: session.sessionManager.getLeafId(), + }); + case 'get_last_assistant_text': + return success(id, 'get_last_assistant_text', { text: session.getLastAssistantText() }); + case 'set_session_name': + session.setSessionName(requiredString(command.name, 'Session name')); + return success(id, 'set_session_name'); + case 'get_messages': + return success(id, 'get_messages', { messages: session.messages }); + case 'get_commands': { + const commands = []; + for (const item of session.extensionRunner.getRegisteredCommands()) { + commands.push({ + name: item.invocationName, + description: item.description, + source: 'extension', + sourceInfo: item.sourceInfo, + }); + } + for (const item of session.promptTemplates) { + commands.push({ + name: item.name, description: item.description, source: 'prompt', sourceInfo: item.sourceInfo, + }); + } + for (const item of session.resourceLoader.getSkills().skills) { + commands.push({ + name: 'skill:' + item.name, + description: item.description, + source: 'skill', + sourceInfo: item.sourceInfo, + }); + } + return success(id, 'get_commands', { commands }); + } + default: + throw new Error('Unknown command: ' + command.type); + } + } + + async dispose() { + if (this.closed) return; + this.closed = true; + this.unsubscribe(); + this.unsubscribeBackpressure(); + for (const pending of this.pendingExtensionRequests.values()) pending.cancel(); + this.pendingExtensionRequests.clear(); + await this.runtime.dispose(); + } +} + +async function handleControl(command) { + switch (command.type) { + case 'server_initialize': + return success(command.id, command.type, { + protocolVersion: PROTOCOL_VERSION, + threadCount: threads.size, + }); + case 'thread_open': { + const threadId = requiredString(command.threadId, 'Thread id'); + if (threads.has(threadId) || openingThreads.has(threadId)) { + throw new Error('Agent thread already exists'); + } + openingThreads.add(threadId); + let thread; + try { + thread = await createThreadRuntime(threadId, command.options); + await thread.start(); + threads.set(threadId, thread); + return success(command.id, command.type, { threadId }); + } catch (error) { + await thread?.dispose().catch(() => undefined); + throw error; + } finally { + openingThreads.delete(threadId); + } + } + case 'thread_close': { + const threadId = requiredString(command.threadId, 'Thread id'); + const thread = threads.get(threadId); + threads.delete(threadId); + if (thread) await thread.dispose(); + return success(command.id, command.type, { threadId, closed: Boolean(thread) }); + } + case 'server_shutdown': + setTimeout(() => { void shutdown(0); }, 0); + return success(command.id, command.type, { threadCount: threads.size }); + default: + throw new Error('Unknown server command: ' + command.type); + } +} + +async function handleEnvelope(line) { + let envelope; + try { + envelope = JSON.parse(line); + } catch (error) { + process.stderr.write('Invalid Makelore Agent Server JSON: ' + String(error) + '\n'); + await shutdown(1); + return; + } + const value = record(envelope); + const payload = record(value?.payload); + if (!value || typeof value.channel !== 'string' || !payload || typeof payload.type !== 'string') { + process.stderr.write('Invalid Makelore Agent Server envelope\n'); + await shutdown(1); + return; + } + try { + const response = value.channel === SERVER_CHANNEL + ? await handleControl(payload) + : await threads.get(value.channel)?.handle(payload); + if (response) output(value.channel, response); + else if (value.channel !== SERVER_CHANNEL && !threads.has(value.channel)) { + output(value.channel, failure(payload.id, payload.type, new Error('Agent thread is unavailable'))); + } + await outputGuard.waitForRawStdoutBackpressure(); + } catch (error) { + output(value.channel, failure(payload.id, payload.type, error)); + await outputGuard.waitForRawStdoutBackpressure(); + } +} + +async function shutdown(exitCode) { + if (shuttingDown) return; + shuttingDown = true; + detachInput(); + const active = [...threads.values()]; + threads.clear(); + await Promise.allSettled(active.map((thread) => thread.dispose())); + shellModule.killTrackedDetachedChildren(); + process.stdin.pause(); + await outputGuard.flushRawStdout(); + process.exit(exitCode); +} + +const onInputEnd = () => { void shutdown(0); }; +process.stdin.on('end', onInputEnd); +const detachJsonl = jsonl.attachJsonlLineReader(process.stdin, (line) => { + void handleEnvelope(line); +}); +detachInput = () => { + detachJsonl(); + process.stdin.off('end', onInputEnd); +}; + +for (const signal of process.platform === 'win32' ? ['SIGTERM'] : ['SIGTERM', 'SIGHUP']) { + process.on(signal, () => { void shutdown(signal === 'SIGHUP' ? 129 : 143); }); +} + +await new Promise(() => undefined); diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 60d6863..3f58291 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -53,6 +53,7 @@ function writeMeowaReleaseCredential(context) { const PERSISTED_USER_DATA_FILES = new Set([ 'gateway-prelaunch-maintenance-cache.json', + 'makelore-projects.json', 'niancode-device-identity.json', 'niancode-providers.json', // Legacy installed-user store filename; it must never enter an artifact. diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs index 8aa92b2..028283c 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -270,8 +270,9 @@ export async function verifyPiProductArtifact({ projectRoot, executable }) { if (!await pathExists(appExecutable)) throw new Error(`Product executable is missing: ${appExecutable}`); const resourcesDirectory = packagedResourcesDirectory(appExecutable); const runtimeRoot = join(resourcesDirectory, 'pi-runtime'); + const agentServerPath = join(resourcesDirectory, 'resources', 'pi-agent-server.mjs'); const appAsar = join(resourcesDirectory, 'app.asar'); - for (const required of [runtimeRoot, appAsar]) { + for (const required of [runtimeRoot, agentServerPath, appAsar]) { if (!await pathExists(required)) throw new Error(`Product artifact resource is missing: ${required}`); } @@ -351,6 +352,7 @@ export async function verifyPiProductArtifact({ projectRoot, executable }) { appAsar, runtimeRoot, cliPath: join(runtimeRoot, ...PI_RUNTIME_CLI_ENTRY.split('/')), + agentServerPath, }, platform: { platform: runtimePlatform.platform, diff --git a/scripts/run-pi-release-performance.mjs b/scripts/run-pi-release-performance.mjs index 2de6096..0f3b4b0 100644 --- a/scripts/run-pi-release-performance.mjs +++ b/scripts/run-pi-release-performance.mjs @@ -237,7 +237,7 @@ export async function runPiReleasePerformance(options) { { id: 6, name: 'same-project mutation lease serialization and cancellation', evidence: ['pi-write-lease.test.ts', 'pi-worker-pool.test.ts'], result: 'pass' }, { id: 7, - name: '4 parent + 4 child final-product pressure, interactive UI, and cleanup', + name: '4 parent threads in 1 Agent Server + 4 child process pressure, interactive UI, and cleanup', evidence: { samples: productProofs.length, packagedMain: productProofs.every(({ packagedMain }) => packagedMain.appPathUsesAsar), diff --git a/scripts/run-pi-subagent-packaged-smoke.mjs b/scripts/run-pi-subagent-packaged-smoke.mjs index 7661195..b668b14 100644 --- a/scripts/run-pi-subagent-packaged-smoke.mjs +++ b/scripts/run-pi-subagent-packaged-smoke.mjs @@ -62,12 +62,12 @@ function assertActivePressure(pressure) { const expected = { parentWorkers: 4, childWorkers: 4, - parentProcesses: 4, + parentProcesses: 1, childProcesses: 4, - liveProcesses: 8, + liveProcesses: 5, parentProviderRequests: 4, childProviderRequests: 4, - processBudget: 8, + processBudget: 4, childPermits: 4, dispatches: 4, writeLeases: 4, @@ -86,7 +86,7 @@ function assertActivePressure(pressure) { writeLeases: pressure?.writeLeases?.active, }; if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error(`PI pressure did not reach 4 parent + 4 child state: ${JSON.stringify(actual)}`); + throw new Error(`PI pressure did not reach 4 parent threads + 4 child process state: ${JSON.stringify(actual)}`); } } @@ -356,7 +356,7 @@ export async function runPackagedProductProof(options) { if (extension.extension?.subagentStatus !== 'complete' || extension.extension?.childToolNames?.join(',') !== 'find,grep,ls,read' || !extension.extension?.parentToolNames?.includes('subagent') - || extension.extension?.parentProcessIds?.length < 2 + || extension.extension?.parentProcessIds?.length !== 1 || extension.extension?.childProcessIds?.length !== 1 || extension.extension?.providerRequests?.child !== 1 || extension.extension?.managedTurns?.length !== 2) { @@ -808,7 +808,7 @@ export async function runPackagedProductProof(options) { if (protocolFailure.resilience.terminalizationMs > 2_000) { throw new Error(`Packaged protocol convergence exceeded 2s: ${protocolFailure.resilience.terminalizationMs}`); } - await page.getByText('The local Agent protocol failed').waitFor({ state: 'visible', timeout: 10_000 }); + await page.getByText('本地 Agent 通信异常,当前对话已停止。').waitFor({ state: 'visible', timeout: 10_000 }); const recoveredAfterProtocol = await recoverSelectedConversation(page, electronApplication); if (JSON.stringify(recoveredAfterProtocol.resilience.providerRequests) !== JSON.stringify(protocolFailure.resilience.status.providerRequests)) { diff --git a/shared/coding-project-contracts.ts b/shared/coding-project-contracts.ts index 2ad514a..eaf73b2 100644 --- a/shared/coding-project-contracts.ts +++ b/shared/coding-project-contracts.ts @@ -32,7 +32,6 @@ export interface CodingProjectConfig { initialized: boolean; agents: CodingProjectAgent[]; knowledgeDirectory: 'knowledge'; - legacyConversationNotice: 'none' | 'pending' | 'acknowledged'; createdAt: string; updatedAt: string; } diff --git a/shared/imported-model-profile.ts b/shared/imported-model-profile.ts index 3e8e0b4..f74892d 100644 --- a/shared/imported-model-profile.ts +++ b/shared/imported-model-profile.ts @@ -6,7 +6,10 @@ export interface ImportedPiModelProfile { reasoning: boolean; thinkingLevelMap?: Partial>; compat?: { - thinkingFormat?: 'deepseek' | 'openrouter'; + thinkingFormat?: 'deepseek' | 'openrouter' | 'qwen'; + supportsDeveloperRole?: boolean; + supportsReasoningEffort?: boolean; + supportsStore?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; }; } @@ -24,9 +27,12 @@ export interface ImportedModelProfile { pi?: ImportedPiModelProfile; } -const VERIFIED_QWEN_PLUS_MODEL_IDS = new Set([ +const VERIFIED_QWEN_36_PLUS_MODEL_IDS = new Set([ 'qwen3.6-plus', 'qwen3.6-plus-2026-04-02', +]); +const VERIFIED_QWEN_PLUS_MODEL_IDS = new Set([ + ...VERIFIED_QWEN_36_PLUS_MODEL_IDS, 'qwen3.7-plus', 'qwen3.7-plus-2026-05-26', ]); @@ -42,7 +48,8 @@ function imageModalities(): ImportedModelProfile['modalities'] { export function getImportedModelProfile(rawModelId: string): ImportedModelProfile | null { const modelId = rawModelId.trim(); - if (modelId.toLowerCase() === 'deepseek-v4-pro') { + const normalizedModelId = modelId.toLowerCase(); + if (normalizedModelId === 'deepseek-v4-pro') { return { modalities: { input: ['text'], @@ -68,7 +75,31 @@ export function getImportedModelProfile(rawModelId: string): ImportedModelProfil }, }; } - if (VERIFIED_QWEN_PLUS_MODEL_IDS.has(modelId.toLowerCase())) { + if (normalizedModelId === 'qwen3.8-max') { + return { + modalities: imageModalities(), + limit: { + context: 1_000_000, + output: 131_072, + }, + pi: { + reasoning: true, + thinkingLevelMap: { + minimal: null, + low: 'low', + medium: 'medium', + high: 'xhigh', + }, + compat: { + thinkingFormat: 'qwen', + supportsDeveloperRole: false, + supportsReasoningEffort: true, + supportsStore: false, + }, + }, + }; + } + if (VERIFIED_QWEN_PLUS_MODEL_IDS.has(normalizedModelId)) { return { modalities: imageModalities(), limit: { @@ -76,6 +107,25 @@ export function getImportedModelProfile(rawModelId: string): ImportedModelProfil output: 65_536, }, visionTokenEstimator: 'qwen-32px-grid', + ...(VERIFIED_QWEN_36_PLUS_MODEL_IDS.has(normalizedModelId) + ? { + pi: { + reasoning: true, + thinkingLevelMap: { + minimal: null, + low: null, + medium: null, + high: 'high', + }, + compat: { + thinkingFormat: 'qwen' as const, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsStore: false, + }, + }, + } + : {}), }; } if (QWEN_VL_PATTERN.test(modelId) || QWEN_OMNI_PATTERN.test(modelId)) { diff --git a/shared/project-config.ts b/shared/project-config.ts index 1dfc563..f657f92 100644 --- a/shared/project-config.ts +++ b/shared/project-config.ts @@ -16,24 +16,6 @@ export type ProjectAgentResponsibility = { principles: string[]; }; -export type ProjectAgentConfig = { - id: string; - avatarId: string; - avatarDataUrl?: string; - roleName: string; - name: string; - builtIn: boolean; - enabled: boolean; - model: string | null; - skillIds: string[]; - responsibility: ProjectAgentResponsibility; - prompt: string; - archivedAt?: string | null; - pinned?: boolean; - createdAt?: string; - updatedAt?: string; -}; - export const MAX_PROJECT_AGENT_AVATAR_DATA_URL_LENGTH = 800_000; const PROJECT_AGENT_AVATAR_DATA_URL_PATTERN = /^data:image\/(?:webp|png|jpeg);base64,[A-Za-z0-9+/]+={0,2}$/u; @@ -43,60 +25,3 @@ export function isProjectAgentAvatarDataUrl(value: unknown): value is string { && value.length <= MAX_PROJECT_AGENT_AVATAR_DATA_URL_LENGTH && PROJECT_AGENT_AVATAR_DATA_URL_PATTERN.test(value); } - -export type ProjectConfig = { - schemaVersion: 1; - projectType: ProjectType; - initialized: boolean; - defaultModel: string | null; - agents: ProjectAgentConfig[]; - knowledgeDirectory: 'knowledge'; - createdAt: string; - updatedAt: string; -}; - -export function createProjectConfig( - now = new Date().toISOString(), - projectType: ProjectType = 'custom', -): ProjectConfig { - return { - schemaVersion: 1, - projectType, - initialized: false, - defaultModel: null, - agents: [], - knowledgeDirectory: 'knowledge', - createdAt: now, - updatedAt: now, - }; -} - -export function validateAgentNames(agents: ProjectAgentConfig[]): string[] { - const errors: string[] = []; - const names = new Set(); - for (const item of agents) { - const name = item.name.trim(); - if (!name) errors.push(`${item.id}:name-required`); - if (name.length > 30) errors.push(`${item.id}:name-too-long`); - const key = name.toLocaleLowerCase(); - if (key && names.has(key)) errors.push(`${item.id}:name-duplicate`); - names.add(key); - } - return errors; -} - -export function validateAgentConfigs(agents: ProjectAgentConfig[]): string[] { - const errors = validateAgentNames(agents); - for (const agent of agents) { - if (!/^avatar-(0[1-9]|1[0-6])$/.test(agent.avatarId)) { - errors.push(`${agent.id}:avatar-required`); - } - if (!agent.model?.trim()) { - errors.push(`${agent.id}:model-required`); - } - if (!agent.responsibility.mission.trim()) { - errors.push(`${agent.id}:responsibility-required`); - } - } - return errors; -} diff --git a/shared/project-conversations.ts b/shared/project-conversations.ts deleted file mode 100644 index 8534f0b..0000000 --- a/shared/project-conversations.ts +++ /dev/null @@ -1,153 +0,0 @@ -export type ProjectSessionMetadata = { - sessionId: string; - agentId: string; - archivedAt: string | null; - unreadCount: number; - createdAt: string; - updatedAt: string; -}; - -export type ProjectConversationState = { - schemaVersion: 1; - sessions: ProjectSessionMetadata[]; - updatedAt: string; -}; - -function cleanString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -function normalizeSession(value: unknown): ProjectSessionMetadata | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const record = value as Partial; - const sessionId = cleanString(record.sessionId); - const agentId = cleanString(record.agentId); - if (!sessionId || !/^[a-z0-9][a-z0-9_-]{0,127}$/i.test(sessionId) || !agentId) return null; - const now = new Date().toISOString(); - const createdAt = cleanString(record.createdAt) || now; - const updatedAt = cleanString(record.updatedAt) || createdAt; - const unreadCount = typeof record.unreadCount === 'number' && Number.isFinite(record.unreadCount) - ? Math.max(0, Math.floor(record.unreadCount)) - : 0; - return { - sessionId, - agentId, - archivedAt: cleanString(record.archivedAt) || null, - unreadCount, - createdAt, - updatedAt, - }; -} - -export function createProjectConversationState(now = new Date().toISOString()): ProjectConversationState { - return { - schemaVersion: 1, - sessions: [], - updatedAt: now, - }; -} - -export function normalizeProjectConversationState(value: unknown): ProjectConversationState { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Project conversation state must be an object'); - } - const record = value as Partial; - if (record.schemaVersion !== 1) throw new Error('Unsupported project conversation state schema'); - const sessions = Array.isArray(record.sessions) - ? record.sessions.map(normalizeSession).filter((item): item is ProjectSessionMetadata => Boolean(item)) - : []; - const deduped = new Map(); - for (const session of sessions) deduped.set(session.sessionId, session); - return { - schemaVersion: 1, - sessions: [...deduped.values()], - updatedAt: cleanString(record.updatedAt) || new Date().toISOString(), - }; -} - -export function upsertProjectSessionMetadata( - state: ProjectConversationState, - sessionId: string, - agentId: string, - now = new Date().toISOString(), -): ProjectConversationState { - const current = state.sessions.find((item) => item.sessionId === sessionId); - const next: ProjectSessionMetadata = { - sessionId, - agentId, - archivedAt: current?.archivedAt ?? null, - unreadCount: current?.unreadCount ?? 0, - createdAt: current?.createdAt ?? now, - updatedAt: now, - }; - return { - schemaVersion: 1, - sessions: [next, ...state.sessions.filter((item) => item.sessionId !== sessionId)], - updatedAt: now, - }; -} - -export function patchProjectSessionMetadata( - state: ProjectConversationState, - sessionId: string, - patch: Partial>, - now = new Date().toISOString(), -): ProjectConversationState { - const sessions = state.sessions.map((item) => item.sessionId === sessionId - ? { - ...item, - ...(patch.agentId ? { agentId: patch.agentId } : {}), - ...(patch.archivedAt !== undefined ? { archivedAt: patch.archivedAt } : {}), - ...(patch.unreadCount !== undefined ? { unreadCount: Math.max(0, Math.floor(patch.unreadCount)) } : {}), - updatedAt: now, - } - : item); - return { schemaVersion: 1, sessions, updatedAt: now }; -} - -/** - * Reading a session changes its unread state, not its activity timestamp. - * Keeping those concerns separate prevents a navigation click from moving an - * Agent in a list that is ordered by the latest conversation activity. - */ -export function markProjectSessionRead( - state: ProjectConversationState, - sessionId: string, - now = new Date().toISOString(), -): ProjectConversationState { - return { - schemaVersion: 1, - sessions: state.sessions.map((item) => item.sessionId === sessionId - ? { ...item, unreadCount: 0 } - : item), - updatedAt: now, - }; -} - -export function removeProjectSessionMetadata( - state: ProjectConversationState, - sessionId: string, - now = new Date().toISOString(), -): ProjectConversationState { - return { - schemaVersion: 1, - sessions: state.sessions.filter((item) => item.sessionId !== sessionId), - updatedAt: now, - }; -} - -export function completeProjectSession( - state: ProjectConversationState, - sessionId: string, - incrementUnread: boolean, - now = new Date().toISOString(), -): ProjectConversationState { - const current = state.sessions.find((item) => item.sessionId === sessionId); - if (!current) return state; - return patchProjectSessionMetadata( - state, - sessionId, - { unreadCount: current.unreadCount + (incrementUnread ? 1 : 0) }, - now, - ); -} diff --git a/src/components/coding/AgentCreationDialog.tsx b/src/components/coding/AgentCreationDialog.tsx index 97566f1..50b782f 100644 --- a/src/components/coding/AgentCreationDialog.tsx +++ b/src/components/coding/AgentCreationDialog.tsx @@ -121,7 +121,7 @@ export function AgentCreationDialog({ setInput((current) => ({ ...current, avatarDataUrl: prepared.previewUrl })); setAvatarPickerOpen(false); } catch (error) { - toast.error(error instanceof Error ? error.message : '伙伴头像图片处理失败'); + toast.error(error instanceof Error ? error.message : '智能体头像图片处理失败'); } finally { setAvatarProcessing(false); } @@ -139,11 +139,11 @@ export function AgentCreationDialog({ const name = input.name.trim(); const responsibility = input.responsibility.trim(); if (!name || !responsibility || !input.model) { - toast.error('请填写伙伴名称、职责,并配置伙伴模型。'); + toast.error('请填写智能体名称和职责说明,并配置默认模型。'); return; } if (existingAgentNames.some((agentName) => agentName.trim() === name)) { - toast.error('伙伴名称需要在当前项目内唯一。'); + toast.error('智能体名称需要在当前项目内唯一。'); return; } const submit = editing ? onUpdate : onCreate; @@ -163,46 +163,46 @@ export function AgentCreationDialog({ - {title ?? (editing ? '维护项目伙伴' : '创建项目伙伴')} - 设置伙伴的基础信息,也可以补充提示词和技能。 + {title ?? (editing ? '编辑项目智能体' : '创建项目智能体')} + 配置智能体的身份、默认模型、职责、系统指令和可用技能。
- +
- setInput((current) => ({ ...current, name: event.target.value }))} className="mt-0 flex-1" /> + setInput((current) => ({ ...current, name: event.target.value }))} className="mt-0 flex-1" />
- -

当前伙伴的消息固定使用此模型;如需切换,请在这里修改。

+ +

新对话默认使用此模型;在对话中临时切换不会修改这里的配置。

- + setInput((current) => ({ ...current, responsibility: event.target.value }))} className="mt-2" />
-

高级设置(提示词与技能)

-