diff --git a/.project-docs/00-brief/success-criteria.md b/.project-docs/00-brief/success-criteria.md index 7c86216..711821a 100644 --- a/.project-docs/00-brief/success-criteria.md +++ b/.project-docs/00-brief/success-criteria.md @@ -19,10 +19,10 @@ - 用户模块入口策略必须由 Electron Main 将 Works `/api/auth/me` 安全投影为四个布尔值;缺失对象/字段默认开启,`design` 映射 `painting`,Renderer 不获得 Token 或原始 profile。 - 被关闭模块必须在卡片和根/深层/别名路由两层均无法进入,且在 `MainLayout`/模块初始化前阻断;Code provider 等待 policy hydration,全局 `/settings` 仍可访问,终止性 `401` 清理 Main/Renderer 会话。该客户端入口策略不能代替 API 授权。 - Makelore Code 必须只使用精确 pin 的 Pi `0.84.2` production runtime;产品源码、安装包和资源根不得保留 OpenCode runtime/package/plugin/fallback/双路径。上游 Pi Provider adapter 的精确静态文件例外必须单列,不能误报为产品旧 runtime。 -- 新 project/Agent/Conversation 必须本地原子创建,Composer 在 worker 准备期间仍可编辑。每条 active/warm Conversation 独立绑定 persistent worker/session;Snapshot/Patch、model/thinking、queue、interaction、subagent 和 recovery 不得跨 Conversation 泄漏。 +- 新 project/Agent/Conversation 必须在 `.makelore` 中本地原子创建,不读取或迁移 `.niancode` / `.opencode` 项目元数据;Composer 在逻辑线程准备期间仍可编辑。每条 active/warm Conversation 在单一父 Agent Server 内独立绑定 Runtime/Session/channel/credential/extension context;Snapshot/Patch、model/thinking、queue、interaction、subagent 和 recovery 不得跨 Conversation 泄漏。 - prompt/compact 的 `202` acceptance 与 uncertain 状态必须保持 request/run/process/background ownership,禁止自动重放;迟到 terminal 事件必须 exactly-once 收敛并重新解锁 Composer。手动 compact 在 Pi `0.84.2` 缺少 `agent_settled` 时必须由 correlated RPC 结果正确结束。 -- Provider secret 只能进入目标 worker env。未解析 Conversation 必须 validate → persist resolved model → prepare;确定性 Works user-context 缺失必须失效缓存凭据、停止 Pi 自动重试并投影固定 Provider-auth 错误,而不是表现为 runtime crash、永久 submitting 或不可恢复的 Conversation。 -- top-level worker 并发 4、child 并发 4、warm idle LRU 4、parent/child 总进程预算 8 与同项目 write lease 必须在正常、abort、crash、timeout、rebuild 和 app quit 路径全部归零;运行中的 Conversation 不得因页面隐藏而被停止。 +- Provider secret 只能进入目标父逻辑线程的内存 credential store 或目标 child 进程。未解析 Conversation 必须 validate → persist resolved model → prepare;确定性 Works user-context 缺失必须失效缓存凭据、停止 Pi 自动重试并投影固定 Provider-auth 错误,而不是表现为 runtime crash、永久 submitting 或不可恢复的 Conversation。 +- top-level 逻辑 turn 并发 4、child 进程并发 4、warm idle logical-thread LRU 8、child FIFO 进程预算 8 与同项目 write lease 必须在正常、abort、crash、timeout、rebuild 和 app quit 路径全部归零;运行中的 Conversation 不得因页面隐藏而被停止。Agent Server 退出必须使所有旧 channel 失效、保持 Main/Renderer 存活,并在恢复时只创建一个新 Server。 ## Quality Checks @@ -32,9 +32,9 @@ - Learning 上线前必须部署 Works Square 项目管理、对象存储、README HTTPS URL 校验和 list/detail/media/archive API,并使用真实账号和正式安装包验证发布/下架、分页、README 图片、下载取消、缺失或错误 `Content-Length`、损坏/摘要不匹配 ZIP,以及 Windows 与签名 macOS 保存行为。 - Robot 默认引导路径必须通过聚焦 Renderer/Main/native 测试并保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚。真实 Host API/native seam Electron E2E、Windows Robot 真机、签名 macOS x64/arm64、指定固件镜像与六位码发行契约未完成前,不得宣称完整双平台硬件兼容或端到端配网已经验收。 - 四模块入口策略发布前必须部署 Works migration/API、重新打包客户端,并以真实账号对 Code、Canvas、Learning、Robot 分别执行关闭 smoke;客户端自动化不得代替服务端 API 授权验收。 -- Windows 最终安装包必须通过 Pi production closure、最终 `app.asar` Main、真实 extension/ephemeral child、4 parent + 4 child live-PID、prompt/compact uncertainty、late-settle 和零残留进程验证。loopback/provider-shaped smoke 只能证明协议序列化与本地隔离,`realTurnVerified=false` 时不得宣称真实 Provider Pass。 +- Windows 最终安装包必须通过 Pi production closure、最终 `app.asar` Main、真实 extension/ephemeral child、单一父 Agent Server 内 4 条重叠 Conversation 逻辑线程 + 4 个 child live-PID、单线程关闭、Server 崩溃重启、prompt/compact uncertainty、late-settle 和零残留进程验证。loopback/provider-shaped smoke 只能证明协议序列化与本地隔离,`realTurnVerified=false` 时不得宣称真实 Provider Pass。 - macOS x64/arm64 与 native non-WSL Linux desktop/compositor 的最终安装产物仍是 cross-platform release gate;Windows 或 WSL2/WSLg 通过不能替代这些平台证据。 ## Last Reviewed -2026-08-26 +2026-08-31 diff --git a/.project-docs/10-decisions/adr-006-pi-runtime-hard-cutover.md b/.project-docs/10-decisions/adr-006-pi-runtime-hard-cutover.md index c323d71..9af5e14 100644 --- a/.project-docs/10-decisions/adr-006-pi-runtime-hard-cutover.md +++ b/.project-docs/10-decisions/adr-006-pi-runtime-hard-cutover.md @@ -2,7 +2,8 @@ ## Status -Accepted and implemented on 2026-08-26. +Accepted and implemented on 2026-08-26; amended on 2026-08-31 for the shared +parent Agent Server topology and `.makelore`-only project ownership. ## Context @@ -12,18 +13,20 @@ The former OpenCode integration coupled Renderer state, a shared runtime lifecyc - Pin `@earendil-works/pi-coding-agent` `0.84.2` as the sole Makelore Code production runtime. Do not maintain OpenCode fallback, dual execution, RPC/SDK alternatives, or Renderer access to runtime HTTP/RPC/event types. - Use product-owned schema-v2 project, Agent, Conversation, model, Snapshot/Patch, command, interaction, attachment, file and subagent contracts. Pi wire and Provider details remain private to Electron Main. -- Electron Main owns the single `/api/coding/*` composition, project storage, Provider credentials, managed Prompt/Skill/extension resources, persistent per-Conversation worker/session registry, recovery, event projection, background lifecycle, process budget, subagent scheduler and same-project write lease. -- Keep one persistent worker/session per active or warm Conversation. Limit top-level running workers to 4, warm idle workers to 4, child concurrency to 4, and all parent/child processes to a shared FIFO budget of 8. A coding child shares its parent's project write lease and cannot recursively dispatch subagents. +- Electron Main owns the single `/api/coding/*` composition, project storage, Provider credentials, managed Prompt/Skill/extension resources, the persistent per-Conversation logical Runtime/Session registry, the long-lived parent Agent Server, recovery, event projection, background lifecycle, process budget, subagent scheduler and same-project write lease. +- Run one long-lived parent Pi Agent Server per Coding composition. Each active or warm Conversation receives an isolated logical Runtime, Session, generation, credential store, extension context and JSONL channel inside that process. Limit running parent turns to 4 and warm idle logical threads to 8. An Agent Server exit invalidates every old parent channel and the next recovery starts exactly one replacement Server; it must not exit Electron Main or replay accepted work. +- Keep child Agents as independent short-lived processes. Limit child concurrency to 4 and retain the FIFO child-process budget of 8; shared parent logical threads do not each consume a process lease. A coding child shares its parent's project write lease and cannot recursively dispatch subagents. - Public streaming is Snapshot-first plus bounded `patch-batch` SSE. Generation/sequence recovery is target-only; accepted or uncertain mutations are never replayed automatically. - A prompt or compact confirmation timeout preserves target run permit, process ownership and Main background lease until authoritative success, failure, exit or abort converges exactly once. Page hiding cannot stop an active or uncertain run. Pi `0.84.2` manual compact may terminalize through its correlated RPC result because it does not emit `agent_settled`. -- A new unresolved Conversation validates and persists its resolved model before first worker prepare. Same-account model changes may use target `set_model`; cross-account changes rebuild only the target worker after an active run settles. -- Provider secrets enter only the selected worker environment. The deterministic Works missing user-context response expires the cached gateway credential, fails without replay and projects as a fixed Provider-auth error; it is not a Pi crash. -- Back up v1 metadata and old OpenCode sessions during migration but do not continue them. Remove only exact generated legacy Agent files; preserve unknown or modified files in the migration backup and leave unrelated `.opencode` content alone. +- A new unresolved Conversation validates and persists its resolved model before first logical-thread prepare. Same-account model changes may use target `set_model`; cross-account changes rebuild only the target logical thread after an active run settles. +- Parent Provider secrets enter only the selected logical thread's in-memory credential store; independently spawned child credentials remain scoped to that child process. Secrets never enter argv, catalogs, Renderer state or sibling threads. The deterministic Works missing user-context response expires the cached gateway credential, fails without replay and projects as a fixed Provider-auth error; it is not a Pi crash. +- `.makelore/project.json` and `.makelore/conversations.json` are the only project-owned Coding configuration stores. Do not read, migrate or delete project metadata from `.niancode` or `.opencode`; legacy content remains inert and user-owned. ## Consequences - Makelore Code has one runtime architecture and one product contract instead of a long-lived compatibility seam. -- A single Conversation can recover or rebuild without invalidating siblings, while process and write budgets provide bounded real concurrency. +- A target logical thread can normally recover or rebuild without invalidating siblings. A whole Agent Server exit is deliberately process-wide: all old parent channels fail closed together, then one replacement Server is created on demand while Conversation generations still prevent stale events from returning. +- Parent process startup and Pi module loading are amortized across Conversation threads, while logical-turn, child-process and write budgets preserve bounded concurrency. - Installed-package regressions must be verified against the final `app.asar` and `resources/pi-runtime`, not only workspace tests. - Provider-shaped loopback proves serialization and local isolation only. The user explicitly waived real Provider account testing and accepted authentication, endpoint/proxy/rate-limit, protocol variation, real concurrency and credential-isolation risk; `realTurnVerified=false` must remain visible and is not Pass. - Windows x64 final-package evidence is available. macOS x64/arm64 and native non-WSL Linux desktop/compositor remain release evidence gaps, so the product is not yet cross-platform release-ready. @@ -39,6 +42,9 @@ The former OpenCode integration coupled Renderer state, a shared runtime lifecyc - Release proof and resilience chain: `a795e0c` through `9f05e2d` - Provider-context correction implementation: `a098266` - Integrated delivery: `48a9189` +- Local shared-Agent-Server source snapshot: `33fb31fb285b5cfb00d194a036aaf6e21cf8c5a1` +- Upstream integration base: `62304dc85b3c1069cd656dfacb61ee820e216fa2` +- Integration task: `20260831-merge-upstream-main-7c3a91f2` - Runtime release runbook: `docs/pi-runtime-release-runbook.md` ## Related diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md index ac97e6e..f98edc2 100644 --- a/.project-docs/10-decisions/decision-index.md +++ b/.project-docs/10-decisions/decision-index.md @@ -9,7 +9,7 @@ | ADR-003 | Robot 配网页内扫描并连接 Windows/macOS 热点 | Accepted / implemented with physical release gates pending | 2026-08-16 | Robot Renderer、Host API、Electron Main、Windows WLAN、macOS CoreWLAN/CoreLocation | `adr-003-robot-in-app-hotspot-connection.md` | | ADR-004 | Works Square 统一拥有桌面认证生命周期边界 | Accepted / implemented | 2026-08-19 | Renderer、Host API、Electron Main、Works Square auth facade | `adr-004-square-auth-lifecycle-boundary.md` | | ADR-005 | Learning 采用运营精选项目目录、直接渲染经校验的 HTTPS README 图片、下载不做大小校验并退役课程运行时 | Accepted / implemented, amended 2026-08-20 | 2026-08-20 | Learning Renderer、Host API、Electron Main、Works Square operations/API | `adr-005-learning-project-catalog.md` | -| ADR-006 | Makelore Code 以 Pi `0.84.2` 为唯一 runtime,Main 独占 worker/session/provider/lease,产品只暴露 Snapshot/Patch 合同 | Accepted / implemented | 2026-08-26 | Code Renderer、Host API、Electron Main、Pi runtime、Provider/resource、packaging | `adr-006-pi-runtime-hard-cutover.md` | +| ADR-006 | Makelore Code 以 Pi `0.84.2` 为唯一 runtime,父 Conversation 复用一个 Main-owned Agent Server 并隔离逻辑 Runtime/Session/provider/lease,产品只暴露 Snapshot/Patch 合同 | Accepted / implemented, amended 2026-08-31 | 2026-08-26 | Code Renderer、Host API、Electron Main、Pi runtime、Provider/resource、packaging | `adr-006-pi-runtime-hard-cutover.md` | ## Superseded Decisions diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index ce515b1..8f31df1 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -15,12 +15,12 @@ | 提交绑定 | 云端成功上传响应 | Main → submission binding v2 | 只持久化成功的 app/version/review/hash;落盘失败返回固定告警但不反转提交 | | 运营发布 | Works Square 审核与交付 | 公共 `play_url` | 客户端只消费服务端发布结果;真实合同校验 → OSS/CDN 生产链仍待整链验收 | | 可信发布门禁 | source+built+artifact contract | 服务端逐字节重算/合同校验 → 不可变 Release 固化 → 人工审核 | 服务端不运行项目 Vite;仍是不可绕过权威,未来 runtime 强门禁需可信 verifier | -| AI 编程 Conversation 创建与准备 | 当前 project / Agent / 本地 Conversation | Renderer 本地创建 → `/api/coding/*` → Main schema-v2 store → target Pi worker/session lazy prepare | Composer 与 worker 准备解耦;首次 Conversation 可立即编辑。未解析模型必须先验证并持久化,再打开首个 worker;旧 OpenCode 会话只备份,不继续运行 | -| AI 编程 prompt 与流式投影 | 当前 Conversation draft / attachment refs | Renderer mutation → Host `202` acceptance/dedupe → Main target worker → Pi events → Snapshot + `patch-batch` SSE → Renderer reducer | 每个 Conversation 独立 generation/seq;gap 只恢复目标 Conversation。accepted/uncertain mutation 不自动重放,权威 Snapshot 到达前保留相同 optimistic node identity | -| AI 编程模型与思考强度切换 | 当前 Conversation | Renderer → `/api/coding/conversations/{id}` → Main validate/persist → target runtime | 同账号已解析模型复用 `set_model`;跨账号模型等 active run settled 后只重建目标 worker,旧凭据不会进入新 generation;切换失败不销毁可继续使用的旧 worker | +| AI 编程 Conversation 创建与准备 | 当前 project / Agent / 本地 Conversation | Renderer 本地创建 → `/api/coding/*` → Main `.makelore` schema-v2 store → shared Agent Server 内 target logical Runtime/Session lazy prepare | Composer 与逻辑线程准备解耦;首次 Conversation 可立即编辑。未解析模型必须先验证并持久化,再打开首个逻辑线程;`.niancode` / `.opencode` 项目元数据不读取、不迁移 | +| AI 编程 prompt 与流式投影 | 当前 Conversation draft / attachment refs | Renderer mutation → Host `202` acceptance/dedupe → Main target Agent Server channel → Pi events → Snapshot + `patch-batch` SSE → Renderer reducer | 每个 Conversation 独立 Runtime/Session/credential/extension context/generation/seq;gap 只恢复目标 Conversation。accepted/uncertain mutation 不自动重放,权威 Snapshot 到达前保留相同 optimistic node identity | +| AI 编程模型与思考强度切换 | 当前 Conversation | Renderer → `/api/coding/conversations/{id}` → Main validate/persist → target logical Runtime | 同账号已解析模型复用 `set_model`;跨账号模型等 active run settled 后只重建目标逻辑线程,旧凭据不会进入新 generation;切换失败不销毁可继续使用的旧线程 | | AI 编程上下文压缩 | Renderer compact action / Pi compaction events | correlated compact RPC → target projector → Snapshot/Patch timeline | Pi `0.84.2` 的手动 compact 不发 `agent_settled`;RPC success 或权威 compaction failure 终结 compact 并 exactly-once 释放对应 ownership,不提前结算普通 prompt | -| AI 编程 Provider 错误 | Pi worker Provider 请求 | selected worker env → Main AI proxy → Works 模型上游 → projector safe error | 确定性的 Works user-context 缺失使缓存 gateway credential 过期、返回非重试认证失败且不重放请求;Renderer 只看到固定 Provider-auth 提示,不能把它解释为 Pi 崩溃 | -| AI 编程子 Agent | parent `subagent.v1` tool call | explicit extension → authenticated Main bridge → scheduler → ephemeral Pi child | child 并发最多 4、单次最多 8、禁止递归;父子共享总进程预算 8,coding child 与 parent 共用项目 write lease,父 abort/crash/generation 失效必须清理 child 与 permit | +| AI 编程 Provider 错误 | Pi logical-thread Provider 请求 | selected thread-local credential store → Main AI proxy → Works 模型上游 → projector safe error | 确定性的 Works user-context 缺失使缓存 gateway credential 过期、返回非重试认证失败且不重放请求;Renderer 只看到固定 Provider-auth 提示,不能把它解释为 Pi 崩溃 | +| AI 编程子 Agent | parent logical thread `subagent.v1` tool call | explicit extension → authenticated Main bridge → scheduler → independent ephemeral Pi child process | child 并发最多 4、单次最多 8、禁止递归;child 使用 FIFO 进程预算 8,shared parent 逻辑线程不各占一个 process lease;coding child 与 parent 共用项目 write lease,父 abort/crash/generation 失效必须清理 child 与 permit | | 客户端更新检查 | 设置页 | Renderer update store → IPC → Main AppUpdater → 目标 feed | Main 记录并重抛原始错误;Renderer 只显示去重、脱敏的单条提示,稳定源缺包不伪装为最新版 | | Design Workspace load | Canvas/Sidebar | Renderer Host API → Main-owned Works Square V2 adapter → current Workspace/Direction/Living Form projection | 一个 Workspace 公开一个 current Direction 与 persistent Agent Session;conversation timeline 是交互历史,不是独立 authority object | | Design input and reconciliation | Chat/direct edit/decision/proposal/lock/Asset binding/restore | Renderer draft → Main → `design.input.apply` → canonical Direction projection | 全部进入同一 reducer;accepted 前 draft 保持本地,revision conflict 刷新权威投影,unknown result 复用原 command/operation identity | @@ -37,7 +37,7 @@ ## State Ownership - Main 持有刷新凭据、发布 Token、固定 npm runtime、源码/构建归档、临时目录、幂等键和 submission binding v2;Renderer 不持有归档路径、构建 origin 或自动部署状态。 -- 项目内 `.niancode/project.json` 保存 `ProjectType`;Main 在配置写入和目录复用时保持其不可变,并在打包时重新读取校验。 +- 项目内 `.makelore/project.json` 是 `ProjectType`、Agent 与 Coding 项目配置的唯一权威;Main 在配置写入和目录复用时保持 `ProjectType` 不可变,并在打包时重新读取校验。 - Renderer 仅持有短效公开会话状态和提交展示状态。 - Renderer 可持久化当前账号的四布尔模块入口策略,但不持有原始 Works profile 或 Token。新账号不继承上一账号缓存;网络/暂时上游失败可保留同会话已知策略,终止性 `401` 不得回退到默认开启。 - 本地构建临时目录、HTTP origin 和预检 WebContents/partition 只属于一次调用;预检读取与 `built_archive` 相同的内存字节,但结果不写为可信上传 receipt,也不覆盖生产 opaque-origin。 @@ -49,13 +49,13 @@ - 图生图参考图与视频首帧都先归一为当前 Workspace Asset,再通过 typed binding 写入 Specification;本地路径不会进入 canonical contract。 - Development 与 packaged Canvas 都使用 Main-owned Works Square V2 adapter。V1 DTO/local semantic adapter 与 local/cloud mode switches 已删除,不存在失败回退。 - 注销和退出关闭本地 event stream、清除 drafts/pending state 和凭据;服务端 current Direction Session 与 semantic history 保持持久化。 -- project、Agent、Conversation schema v2 与每条 Conversation 的 opaque Pi session binding 由 Main 原子持久化。旧 v1 先做时间戳备份再迁移;旧 OpenCode Session 不进入新 runtime,也不删除无关 `.opencode` 内容。 +- project、Agent、Conversation schema v2 与每条 Conversation 的 opaque Pi session binding 由 Main 在 `.makelore/project.json` 和 `.makelore/conversations.json` 原子持久化。当前客户端不读取、迁移或删除 `.niancode` / `.opencode` 项目元数据。 - Renderer Store 只拥有产品中立 Snapshot、Patch、optimistic request 与 project-scoped metadata。每条 Conversation 的 generation/seq 独立;stale generation 丢弃,gap/reconnect 只为目标 Conversation 取 Snapshot 并重放严格连续的缓冲 tail,不重放 mutation。 -- Main 为每条 active/warm Conversation 持有一个 persistent Pi worker/session、Provider/resource revision 和 selected-account secret env。运行中 stale revision 保留本 run snapshot,settled 后重建;其他 Conversation 不受影响。 -- top-level worker 并发上限 4、warm idle LRU 上限 4、child 并发上限 4,parent/child 共用 FIFO process budget 8。同项目 coding mutation 共用 write lease;read-only child 可旁路写锁。 +- Main 以一个长驻 Agent Server 承载每条 active/warm Conversation 的隔离 Pi Runtime/Session/channel、Provider/resource revision 和 thread-local credential store。运行中 stale revision 保留本 run snapshot,settled 后只重建目标逻辑线程;整个 Server 退出则使所有旧 channel 失效,下次恢复只启动一个新 Server。 +- top-level 逻辑 turn 并发上限 4、warm idle logical-thread LRU 上限 8、child 并发上限 4,child 使用 FIFO process budget 8;shared parent 线程不各占一个 process lease。同项目 coding mutation 共用 write lease;read-only child 可旁路写锁。 - prompt/compact 的 RPC confirmation timeout 只进入 uncertain,不能释放 run permit、process ownership 或 Main background lease。迟到 success/failure/exit/abort 必须单调且 exactly-once 收敛;隐藏页面超过 grace 也不得停止 active/uncertain run。 - AI 编程压缩状态属于目标 Conversation Snapshot。Pi `0.84.2` 手动 compact 的 correlated RPC success 可代替缺失的 `agent_settled` 终结 compact;普通 prompt 仍只由权威 terminal/settled 结束。 -- Main Host AI proxy 持有凭据刷新和错误分类。确定性 Works user-context 缺失必须失效缓存 credential 并 fail fast;真实认证、endpoint/proxy/rate-limit、Provider variation、真实并发与跨 worker 凭据隔离仍是用户明确接受但未验证的风险。 +- Main Host AI proxy 持有凭据刷新和错误分类。确定性 Works user-context 缺失必须失效缓存 credential 并 fail fast;真实认证、endpoint/proxy/rate-limit、Provider variation、真实并发与共享 Agent Server 内的跨线程凭据隔离仍是用户明确接受但未验证的风险。 - Main AppUpdater 持有 feed、原始异常、下载和安装状态;设置页只消费状态投影。一次 electron-updater `error` 事件覆盖的并发检查不会在 Renderer 重复发错,但独立的后续检查仍有自己的报告生命周期。 - Robot V1 引导状态只在 Renderer 当前进程内保存,不持久化 Wi-Fi 凭据、activation code 或 Binding operation ID。相同进程内的模糊 Binding 重试复用 operation ID;无效码或重启后必须取得新码并使用新 operation ID。 - Robot Hotspot Module 只在 Main 内保存最近一次扫描的短效、不透明候选快照。新的扫描、clear、60 秒过期或进程重启使旧 candidate ID 失效;Renderer 关闭/重开向导以 generation 防止旧扫描/连接结果回写。 @@ -75,4 +75,4 @@ ## Last Updated -2026-08-30 +2026-08-31 diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index 50b3495..36e8da3 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -6,7 +6,7 @@ |---|---|---| | `electron/coding-plugins/manifest.ts`, `release-descriptor.ts`, `signature-verifier.ts`, and `trusted-keys.ts` | Closed Plugin package/descriptor parsing, compatibility checks, canonical archive validation, and Ed25519 trust | Schema 1 bundled compatibility and schema 2 `skill_only`/declarative `platform_hosted` are parsed fail closed. The production trust store accepts no Marketplace package until the official public key is built in. | | `electron/coding-plugins/account-plugin-cache.ts`, `marketplace-client.ts`, and `package-store.ts` | Main-owned Account Library cache, authenticated server client, and atomic immutable device installation store | Account acquisition and device installation are separate. Package bytes switch atomically only after descriptor, signature, archive, client-range, and provenance checks; failed updates preserve the prior current release. | -| `electron/coding-plugins/effective-resolver.ts`, `registry.ts`, `project-service.ts`, and `electron/coding-runtime/pi/**` | One effective installed-plugin projection and frozen parent-worker runtime snapshot | Project enablement and Agent Skill assignment are preserved independently. Only trusted, compatible, installed, policy-admitted resources enter a new parent worker; child workers remain empty and active workers retain frozen bytes until disposal. | +| `electron/coding-plugins/effective-resolver.ts`, `registry.ts`, `project-service.ts`, and `electron/coding-runtime/pi/**` | One effective installed-plugin projection and frozen parent logical-thread runtime snapshot | Project enablement and Agent Skill assignment are preserved independently. Only trusted, compatible, installed, policy-admitted resources enter a new parent logical thread; child workers remain empty and active threads retain frozen bytes until disposal. | | `electron/api/routes/plugin-marketplace.ts`, `src/stores/plugin-marketplace.ts`, `src/pages/PluginMarketplace/`, `src/pages/MyPlugins/`, and `src/pages/ProjectPlugins/` | Bounded Main routes and Renderer projections for catalog, Library, device, and project actions | Renderer receives no Account token, filesystem path, Admission, package bytes, or signed download URL. Catalog/Library/device/project generations prevent stale A/B or cross-action results from replacing current state. | | `src/components/works/ProjectPublishAction.tsx` | 可发布项目的一键提交、云构建轮询与用户可理解状态 | 只通过 Renderer API 提交非敏感元数据;绑定告警不终止轮询 | | `src/lib/works-square.ts` | Renderer 侧 Works Square Host API 契约与安全错误映射 | 不接触 Token、ZIP、本地绝对路径或自动部署状态 | @@ -23,9 +23,9 @@ | `src/stores/coding-conversations.ts` / `coding-workspace.ts` | per-Conversation Snapshot/Patch、乐观提交、gap recovery 与项目级元数据 | generation/seq 严格递增;只恢复目标 Conversation,SSE 重连不重放 mutation | | `shared/coding-conversation-*.ts` / `coding-project-contracts.ts` | Main/Renderer 共用的产品中立 Conversation、project tool、subagent 与 reducer 合同 | 这是唯一公共实现;Pi 事件、RPC 与 Provider 形状保持 Main-private | | `electron/api/coding-composition.ts` / `electron/api/routes/coding-*.ts` | 唯一 Main-owned Coding composition 与 `/api/coding/*` Host API | 管理 project/Agent/Conversation、SSE、附件、文件、命令、交互和安全诊断;mutation 返回 202 acceptance 且按 request id 去重 | -| `electron/coding-projects/` | schema-v2 项目/Agent/Conversation 数据、原子持久化、旧 v1 备份迁移与项目文件能力 | 旧 OpenCode 会话只备份不续写;modified/unknown legacy Agent 文件先备份,valid v2 不读取旧 runtime 数据 | +| `electron/coding-projects/` | `.makelore/project.json` 与 `.makelore/conversations.json` schema-v2 项目/Agent/Conversation 数据、原子持久化与项目文件能力 | 不读取或迁移 `.niancode` / `.opencode` 项目元数据;legacy 内容保持 inert 且不删除 | | `electron/coding-runtime/conversation-service.ts` | 产品级 Conversation orchestration、模型/思考强度、fork/archive/recover 与运行时门禁 | unresolved Conversation 先验证并持久化模型再首次 prepare;已解析 Conversation 的模型切换复用 target runtime seam | -| `electron/coding-runtime/pi/{worker-pool,worker-process,rpc-client,session-registry,event-projector}.ts` | 一 Conversation 一 persistent Pi worker/session、严格 LF JSONL RPC、generation recovery 与 Snapshot/Patch 投影 | top-level cap 4、共享总进程预算 8、warm idle LRU 4;worker 替换只使目标 generation 失效 | +| `electron/coding-runtime/pi/{agent-server-process,worker-pool,worker-process,rpc-client,session-registry,event-projector}.ts` / `resources/pi-agent-server.mjs` | 一个长驻父 Agent Server 内的 per-Conversation 逻辑 Runtime/Session/channel、严格 LF JSONL RPC、generation recovery 与 Snapshot/Patch 投影 | top-level logical-turn cap 4、warm idle LRU 8;线程级替换只使目标 generation 失效,Server 退出使所有旧 channel 失效并在下次恢复时只重启一个 Server | | `electron/coding-runtime/pi/{provider-config,provider-refresh,resource-loader}.ts` / `electron/api/routes/ai-proxy.ts` | Main-owned Provider catalog、secret/env 投影、资源 revision、一次凭据刷新与上游错误分类 | secret 不进入 argv/catalog/Renderer;确定性的 Works user-context 缺失使缓存凭据过期并 fail fast,不自动重放 mutation | | `electron/coding-runtime/pi/{extension-host,subagent,write-lease}.ts` / `electron/main/background-lifecycle.ts` | 唯一显式 Makelore extension、交互、子 Agent、同项目写租约与后台运行租约 | child 并发 4、单次最多 8、禁止递归;active/uncertain run 不因页面隐藏或确认超时被回收,所有 stop/replacement 必须有原因 | | `electron/main/updater.ts` | 目标 feed 解析、electron-updater 生命周期与原始诊断 | 缺少稳定 manifest 保持错误;事件/Promise 重复失败按检查实例去重 | @@ -53,7 +53,7 @@ ## Dependency Direction - Renderer UI → Renderer API contract → Main Host routes → Main services → Works Square;Renderer 不反向读取 Main 凭据、文件系统或归档。 -- AI 编程 Renderer product Snapshot/commands → typed `/api/coding/*` Host API → Main Coding composition → target `CodingConversationRuntime` → persistent Pi worker;Pi Provider 请求再经 Main AI proxy 访问模型上游。Renderer 不持有 Pi wire、凭据或本地 runtime URL。 +- AI 编程 Renderer product Snapshot/commands → typed `/api/coding/*` Host API → Main Coding composition → target `CodingConversationRuntime` → shared Agent Server 内的目标 Pi 逻辑线程;Pi Provider 请求再经 Main AI proxy 访问模型上游。Renderer 不持有 Pi wire、凭据或本地 runtime URL。 - Project configuration 决定产品分流;Main release builder 生成 source/built/contract,服务端独立重算和校验决定发布安全,本地 `ProjectType` 不是授权结论。 - Built artifact preflight 检查最终上传的同字节快照,但客户端可被绕过且不产生可信 receipt;服务端仍是合同、摘要和不可变 Release 安全权威。 - Robot Renderer → typed AI hardware API → Main Host route → Robot Hotspot Module → Windows/macOS Adapter。云端 Binding 仍由 Main 代理;热点选择/连接移入页面,但家庭 Wi-Fi 凭据输入仍只留在固件 Portal,系统 Wi-Fi 保留为兜底。 @@ -69,8 +69,8 @@ - `runtime_url` 是一个客户端版本的迁移回退;删除前必须确认服务端和存量数据稳定提供 `play_url`。 - Pi RPC confirmation timeout is an uncertainty boundary, not permission to release run/process/background ownership. Late success/failure/exit/abort must converge monotonically and exactly once; no accepted or uncertain mutation may be replayed automatically. - Pi manual compaction in locked `0.84.2` does not emit `agent_settled`; correlated compact RPC success or an authoritative compaction failure must terminalize the compact operation without prematurely settling an ordinary prompt. -- Parent and child workers share one FIFO process budget of 8. Child reservation may reclaim ready/idle or queued parent capacity without losing FIFO order, session binding, or generation ownership; stop/reclaim failure must release leases in `finally`. -- Provider/resource revisions apply before the next prompt for idle workers and after `agent_settled` for active runs. Same-account model changes may use `set_model`; cross-account changes rebuild only the target Conversation so old credentials never enter the new worker. +- Shared parent logical threads do not consume one process lease each. Independent child Agents remain capped at 4 against the FIFO process budget of 8; stop failure must release child leases in `finally`. +- Provider/resource revisions apply before the next prompt for idle logical threads and after `agent_settled` for active runs. Same-account model changes may use `set_model`; cross-account changes rebuild only the target Conversation thread so old credentials never enter the new credential store. - `electron/main/updater.ts` 的稳定源错误归一化必须保持窄化:只识别 Works Square 对应 manifest 的 404,不得吞掉其他 feed/网络/签名错误;Renderer 的脱敏边界不能取代 Main 原始日志。 - Design V2 事件处理必须区分 Direction projection 与 Workspace Task/Asset projection;不得用 transport sequence 或 Task progress 推进 Specification revision。 - 输入、Quote request、confirmation 和项目切换跨 Renderer/Main/Works Square。异步结果必须核对 Workspace、Direction、revision 与 operation identity;unknown write 复用原 identity,不能生成新业务意图。 @@ -83,4 +83,4 @@ ## Last Updated -2026-08-30 +2026-08-31 diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 218eab6..5e2bd26 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -20,8 +20,8 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | App Updater | Main 选择平台/架构 feed、驱动 electron-updater 并记录原始诊断 | Renderer 只接收状态和脱敏后的单条用户提示;稳定源缺包保持错误 | | Makelore Code Workspace | project/Agent/Conversation schema v2、产品中立 Snapshot/Patch、Composer 与时间线 | Conversation 本地创建且不等待 worker;旧 OpenCode 会话备份后不再继续,Renderer 不导入 Pi RPC/event 类型 | | Coding Host API & Composition | 唯一 `/api/coding/*` composition、202 acceptance/dedupe、SSE、附件/文件/交互/诊断 | Electron Main 拥有 project/Conversation 服务、选中目标、认证和错误脱敏;SSE 公开面只有 Snapshot 与 `patch-batch` | -| Pi Conversation Runtime | 每条 active/warm Conversation 一个 persistent Pi `0.84.2` worker/session | 严格 LF JSONL RPC、generation recovery、Snapshot hydration;top-level 并发 4、warm idle LRU 4、总进程预算 8 | -| Pi Provider & Managed Resources | Provider catalog、secret/env 投影、model/resource revision、Prompt/Skill/extension materialization | 凭据只进入选中 worker env;不扫描项目或用户的 `.pi/.agents/.codex`,不把 secret 放进 argv、catalog 或 Renderer | +| Pi Conversation Runtime | 一个长驻 Pi `0.84.2` Agent Server 承载每条 active/warm Conversation 的隔离逻辑 Runtime/Session/JSONL channel | 严格 LF JSONL RPC、generation recovery、Snapshot hydration;top-level 逻辑 turn 并发 4、warm idle LRU 8;Server 退出统一使旧 channel 失效并按需单实例重启 | +| Pi Provider & Managed Resources | Provider catalog、thread-local secret projection、model/resource revision、Prompt/Skill/extension materialization | 父凭据只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;不扫描项目或用户的 `.pi/.agents/.codex`,不把 secret 放进 argv、catalog 或 Renderer | | Pi Extension, Subagents & Lifecycle | 唯一显式 Makelore extension、UI interaction、ephemeral child、write lease 与 background run lease | child 并发 4、单次最多 8、禁止递归;active/uncertain run 不因页面隐藏或 confirmation timeout 被停止,replacement/stop 必须可解释并清理所有 ownership | | AI Design Workspace & Living Form | 一个 Workspace 的当前 Direction、Current Specification、持久 Agent Session、conversation timeline、Tasks 与 Assets | Living Form 是服务端 Current Specification 的投影;Renderer 只持有草稿和已接受投影 | | AI Design Input & Reconciliation | Chat、字段/集合编辑、decision、proposal、lock、Asset binding 与 restore | 全部进入同一 `design.input.apply` reducer;稳定 command/operation ID 支持 unknown-result 重放,revision conflict 刷新权威状态 | @@ -55,7 +55,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 - Makelore Code 的唯一 production runtime 是精确 pin 的 Pi `0.84.2`;不得恢复 OpenCode fallback、双 runtime、兼容执行路径或 Renderer 直连本地 runtime。上游 `@earendil-works/pi-ai` 内名称含 `opencode` 的静态 Provider adapter 不属于产品旧 runtime。 - Renderer 只消费共享的产品中立 project/Conversation/Snapshot/Patch DTO,并经 `src/lib/host-api.ts` 或 `src/lib/api-client.ts` 访问 Main。Pi RPC、event、Provider credential 和本地 runtime 路径保持 Main-private。 - accepted/uncertain Coding mutation 不得自动重放;RPC confirmation timeout 后仍保留 target run permit、process ownership 和 background lease,直到迟到 success/failure/exit/abort 权威收敛。其他 Conversation 必须继续可用。 -- selected Provider credential 只投影到目标 worker env;跨账号模型变化必须重建目标 worker。确定性 Works user-context 缺失是 Provider-auth failure:失效缓存 credential、fail fast、固定脱敏提示,不得归类为 Pi worker crash。 +- selected Provider credential 只投影到目标父逻辑线程的内存 credential store 或目标 child 进程;跨账号模型变化必须重建目标逻辑线程。确定性 Works user-context 缺失是 Provider-auth failure:失效缓存 credential、fail fast、固定脱敏提示,不得归类为 Pi crash。 - Guided Hotspot Binding is implemented behind a Main-owned capability that is true by default; exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` disables it, while capability-read failure falls back to direct six-digit Binding. The guided state is process-local, opener failures expose only the same fixed address for manual copy, and Binding conflicts refresh the safe account overview. - Robot hotspot scanning and connection are local Main operations that return before Works credentials/upstream access. Renderer may submit only an opaque candidate ID from the latest bounded scan; Main alone filters open printable `Xiaozhi-*` SSIDs, performs platform association, and verifies the exact current SSID. - Hotspot discovery and connection do not authenticate a Robot. BSSID, interface/profile details, native diagnostics, location data, and Wi-Fi credentials never cross the Main boundary; permission or platform failure keeps the system-settings/manual path available. @@ -91,4 +91,4 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 ## Last Updated -2026-08-30 +2026-08-31 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 29260a9..754c279 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,18 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- Local source snapshot `33fb31fb285b5cfb00d194a036aaf6e21cf8c5a1`, + based on the prior local delivery `48a9189`, is integrated over fetched upstream + `62304dc85b3c1069cd656dfacb61ee820e216fa2` in merge commit + `28897cd4a2b7179d9ccb444218a1d74a2bbc004b` by task + `20260831-merge-upstream-main-7c3a91f2`. The integration preserves Plugin + Marketplace Release A and AI Design Living Form V2 while adopting one long-lived + parent Pi Agent Server with isolated per-Conversation logical Runtime, Session, + credential store, extension context, generation and JSONL channel. Parent logical + turns remain capped at 4, warm idle threads at 8, and independent child processes + at 4 against the FIFO process budget of 8. `.makelore/project.json` and + `.makelore/conversations.json` are authoritative; current code does not read or + migrate project metadata from `.niancode` or `.opencode`. - MakeLore curated Plugin Marketplace Release A source `40df677a31ff7651f962151eb84b925987781c03` from task `20260828-plugin-marketplace-client-5f8b3d72` is integrated by task @@ -38,13 +50,15 @@ This file is the integrated default-branch snapshot. Feature tasks record progre `20260826-integrate-pi-provider-fix-6e4c2a91`. The strict 101-commit fast-forward replaces OpenCode with pinned Pi `0.84.2` as Makelore Code's sole production runtime, adds schema-v2 project/Agent/Conversation storage, - persistent per-Conversation workers, product Snapshot/Patch contracts, + the predecessor persistent per-Conversation worker topology, product Snapshot/Patch contracts, `/api/coding/*`, Provider/resource isolation, extension/subagents, process and write budgets, and background-run uncertainty ownership. Implementation `a098266` additionally validates/persists an unresolved Conversation model before first prepare and converts the exact Works missing user-context response into a non-replayed Provider-auth failure after expiring the cached - gateway credential. The final Windows installer and final packaged Pi proof + gateway credential. Task `20260831-merge-upstream-main-7c3a91f2` supersedes + only that parent-process topology with the shared Agent Server described above. + The final Windows installer and final packaged Pi proof passed; real Provider turns remain explicitly waived with `realTurnVerified=false`, while macOS x64/arm64 and native non-WSL Linux remain unverified. Older OpenCode entries below are retained only as @@ -176,7 +190,7 @@ Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/ap AI 学习现在是已启用的运营精选项目目录,并继续受登录和 `module_access.learning` 控制。Renderer 通过 Main-owned Host API 获取分页项目卡片和 README 详情;Markdown 支持 GFM、禁用原始 HTML。服务端发布时只校验图片 URL 为无凭据、默认端口、无 fragment 且当前 DNS 结果全部为公网地址的 HTTPS URL,保留地址而不下载、识别格式、转码或镜像;客户端仅为 README 图片节点启用直连,因此 SVG 和 Electron 支持的其他格式可直接显示,单图失败不阻断详情。封面和历史发布媒体继续走受控路径。详情页的下载按钮打开系统保存对话框;Main 将 ZIP 流式写入临时文件,只允许最多五跳同 Works origin 重定向,不校验 `Content-Length`、`archiveBytes`、实际流字节数或客户端大小上限,校验 SHA-256 和 ZIP 签名后原子保存,Renderer 只接收 `saved` 或 `cancelled`。课程生成、进度、本地课程库、OpenMAIC player、Agent、ASR、课堂 runtime、Learning IPC 和 player artifact 打包已删除且没有兼容读取路径;历史课程数据保留但不再读取。服务端和客户端源码契约已完成,不代表生产部署或真实账号安装包联调已经完成。 -AI 编程已经硬切到精确 pin 的 Pi `0.84.2`,不存在 OpenCode fallback 或双 runtime。project、Agent 与 Conversation 采用本地 schema v2;旧 OpenCode 会话只做备份,不继续执行。每条 active/warm Conversation 拥有独立 persistent worker/session、generation/seq、Snapshot/Patch、model/thinking、队列、interaction 与错误状态,Composer 在 lazy prepare 期间仍可编辑。Renderer 只通过 `/api/coding/*` 和 Snapshot-first/`patch-batch` SSE 消费产品中立合同;gap/reconnect 只恢复目标 Conversation,accepted/uncertain mutation 不自动重放。未解析 Conversation 第一次选模先 validate 并持久化 resolved metadata,再 prepare;同账号模型切换使用 target `set_model`,跨账号只重建目标 worker。top-level/child 并发分别为 4,共享总进程预算 8,warm idle LRU 4;coding child 与 parent 共用项目 write lease。prompt/compact confirmation timeout 后仍保留 run/process/background ownership,迟到 success/failure/exit/abort 单调且 exactly-once 收敛,页面隐藏不会停止 active/uncertain run。Pi `0.84.2` 手动 compact 不发 `agent_settled`,由 correlated compact RPC 结果终结。Provider credential 只进入选中 worker env;确定性的 Works user-context 缺失会失效 gateway credential、fail fast、不重放并投影固定 Provider-auth 错误,不归类为 Pi worker crash。真实 Provider 验证仍为用户明确接受的未验证风险,macOS x64/arm64 与 native non-WSL Linux 也未通过平台发布门禁。 +AI 编程已经硬切到精确 pin 的 Pi `0.84.2`,不存在 OpenCode fallback 或双 runtime。project、Agent 与 Conversation 只在 `.makelore/project.json` 和 `.makelore/conversations.json` 使用本地 schema v2;当前客户端不从 `.niancode` 或 `.opencode` 读取、迁移或删除项目元数据。Electron Main 按需启动一个长驻父 Agent Server,每条 active/warm Conversation 在其中拥有独立 Runtime/Session/channel、credential store、extension context、generation/seq、Snapshot/Patch、model/thinking、队列、interaction 与错误状态,Composer 在 lazy prepare 期间仍可编辑。Renderer 只通过 `/api/coding/*` 和 Snapshot-first/`patch-batch` SSE 消费产品中立合同;gap/reconnect 只恢复目标 Conversation,accepted/uncertain mutation 不自动重放。未解析 Conversation 第一次选模先 validate 并持久化 resolved metadata,再 prepare;同账号模型切换使用 target `set_model`,跨账号只重建目标逻辑线程。top-level 逻辑 turn 并发为 4,warm idle logical-thread LRU 为 8;independent child 进程并发为 4 并使用 FIFO 进程预算 8;coding child 与 parent 共用项目 write lease。prompt/compact confirmation timeout 后仍保留 run/Agent-Server-or-child-process/background ownership,迟到 success/failure/exit/abort 单调且 exactly-once 收敛,页面隐藏不会停止 active/uncertain run。线程级替换只使目标 generation 失效;整个 Agent Server 退出会统一使所有旧 channel 失效,但 Main/Renderer 存活且下次恢复只重启一个 Server。Pi `0.84.2` 手动 compact 不发 `agent_settled`,由 correlated compact RPC 结果终结。父 Provider credential 只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;确定性的 Works user-context 缺失会失效 gateway credential、fail fast、不重放并投影固定 Provider-auth 错误,不归类为 Pi crash。真实 Provider 验证仍为用户明确接受的未验证风险,macOS x64/arm64 与 native non-WSL Linux 也未通过平台发布门禁。 Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失败语义。正式稳定源缺少对应平台 manifest 时,设置页只显示一条简洁中文提示并允许重试,不把缺包误报为已是最新版,也不向普通界面暴露堆栈、URL、路径或错误码;签名产物发布和真实升级安装仍属于外部 Release Gate。 @@ -184,6 +198,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Recently Completed +- 2026-08-31: Integrated the occupied local source snapshot over upstream Marketplace Release A and AI Design Living Form V2. Makelore Code now amortizes parent Pi startup through one long-lived Agent Server while preserving isolated per-Conversation logical runtimes, sessions, credentials, extensions and channels; child Agents remain independent processes. ADR-006 and current architecture now use `.makelore` as the sole project configuration source and explicitly reject legacy metadata reads or migration. - 2026-08-26: Fast-forwarded the complete Pi hard-cutover and installed-package resilience chain into local `main`, including per-Conversation workers/Snapshot projection, extension/subagents, bounded process/write/background ownership and the deterministic Works user-context Provider-auth correction. The final Windows installer, packaged Pi runtime, extension/child, 4+4 pressure, uncertainty/late-settle and zero-lingering-process proofs passed. Real Provider turns are explicitly waived rather than passed; macOS and native non-WSL Linux remain release evidence gaps. - 2026-08-20: Replaced AI Learning's course generation/player stack with the curated project catalog defined by ADR-005. The authenticated/module-gated client now renders project cards and safe README detail, loads validated credential-free HTTPS Markdown images directly, and saves verified ZIP archives through the native dialog. Old course/runtime/player packaging was removed; server/client regressions and full suites passed, while production deployment and real-account package smoke remain pending. - 2026-08-19: Integrated native password/SMS login, the temporary HTTP/2-disabled diagnostic bootstrap, and the AI Design freeze fix. Workspace JSON calls and shared token refresh now settle within 30 seconds, transport abort is paired with deterministic rejection, and implicit Electron-to-Node fallback no longer replays mutation requests. Installed-client Quote retry/confirm smoke and the final HTTP/2 policy decision remain pending. @@ -251,11 +266,11 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 - 客户端对最终构建字节的 loopback 检查没有可信 receipt,且不复刻生产 opaque-origin;服务端必须独立重算 source/built/contract、校验不可变 Release,人工审核仍不可绕过。如未来需要不可绕过的 runtime gate,必须引入可信 verifier 并绑定精确构建产物。 - Pi RPC confirmation timeout 是不确定性边界,不是释放 run permit、process ownership 或 Main background lease 的依据;accepted/uncertain mutation 不得自动重放,迟到 terminal 必须 exactly-once 收敛。隐藏/显示、abort、recover、replacement 与 app quit 都必须保留可解释 reason 并最终清零 ownership。 - Pi `0.84.2` 手动 compact 不发 `agent_settled`。只有 correlated compact RPC success 或权威 compaction failure 可以结束 compact;普通 prompt 仍需自己的 terminal/settled 语义,不能相互释放 lease。 -- 本地 provider-shaped loopback 和 4+4 process proof 证明客户端序列化、调度与隔离 seam,不证明真实 Provider 会并发、不会限流或正确隔离账号凭据。真实认证、endpoint/proxy/rate-limit、协议和图片差异仍为 `Explicitly Waived / Accepted Risk`,`realTurnVerified=false`。 -- Provider/resource freshness 属于目标 Conversation worker generation。idle stale 在下一 prompt 前重建,running stale 在 settled 后重建;同账号 refresh single-flight 且最多一次 reopen。确定性 Works user-context 缺失必须失效缓存凭据并 fail fast,不能触发无限 Pi 重试、自动 replay 或把 Provider 故障写成 runtime crash。 +- 本地 provider-shaped loopback 和单一 Agent Server 内 4 条重叠父逻辑线程 + 4 child process proof 证明客户端序列化、调度与隔离 seam,不证明真实 Provider 会并发、不会限流或正确隔离账号凭据。真实认证、endpoint/proxy/rate-limit、协议和图片差异仍为 `Explicitly Waived / Accepted Risk`,`realTurnVerified=false`。 +- Provider/resource freshness 属于目标 Conversation logical-thread generation。idle stale 在下一 prompt 前重建,running stale 在 settled 后重建;同账号 refresh single-flight 且最多一次 reopen。确定性 Works user-context 缺失必须失效缓存凭据并 fail fast,不能触发无限 Pi 重试、自动 replay 或把 Provider 故障写成 runtime crash。 - AI Design 图片/视频引用必须以 typed Asset binding 写入 Specification,不能从本地化 quick reply、V1 Brief 或本地路径推断 action/purpose。 - Updater 源码错误提示不能代替发布正式签名产物;稳定 feed 缺 manifest 必须保持失败,Renderer 不得展示原始堆栈、URL、路径或错误码。升级链只有在旧版本完成发现、下载、重启和安装 smoke 后才可视为生产就绪。 ## Last Updated -2026-08-30 +2026-08-31 diff --git a/.project-docs/30-worklog/tasks/20260831-merge-upstream-main-7c3a91f2.md b/.project-docs/30-worklog/tasks/20260831-merge-upstream-main-7c3a91f2.md new file mode 100644 index 0000000..73a218e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-merge-upstream-main-7c3a91f2.md @@ -0,0 +1,86 @@ +# Task: Merge upstream updates into main + +## Identity + +- Task ID: 20260831-merge-upstream-main-7c3a91f2 +- Mode: Integration +- Branch: codex/20260831-merge-upstream-main-7c3a91f2-merge-upstream-main +- Worktree: /Users/inmanx/Documents/makelore-merge-upstream-main-7c3a91f2 +- Base commit: 62304dc85b3c1069cd656dfacb61ee820e216fa2 +- Owner: codex +- Status: Completed + +## Scope + +- Integrate the occupied local `main` worktree's tracked source changes and intentional source/test additions, based on `48a9189`, onto fetched `origin/main` at `62304dc`. +- Preserve the upstream Plugin Marketplace Release A and AI Design Living Form V2 integrations while resolving overlapping local Code, auth, navigation, Canvas utility, project-data, and documentation changes semantically. +- Exclude the untracked generated `resources/learning-player/` artifact from Git without deleting it locally. +- Promote the verified integration result to the local `main` branch; do not push to the remote. + +## Intent And Constraints + +- Capture the dirty source work as a non-destructive snapshot commit before attempting a three-way merge. +- Do not overwrite the tree with either side wholesale. Preserve Main-owned runtime, authentication, project-data, cloud Canvas, Marketplace trust, and packaging boundaries. +- Treat the user's explicit instruction to merge the local changes into `main` as confirmation to adopt the local single-Agent-Server parent-process topology and reconcile ADR-006/current architecture accordingly. +- Keep source task records read-only. Record integration decisions and verification only in this integration task record and canonical Integration-Gate documents. +- Do not commit dependencies, downloaded runtimes, generated build output, reports, caches, secrets, or local user data. + +## Outcome + +- Captured the occupied local source tree without mutating it as commit + `33fb31fb285b5cfb00d194a036aaf6e21cf8c5a1` on + `codex/local-main-snapshot-20260831-7c3a91f2`. The generated 93 MiB + `resources/learning-player/` directory was deliberately excluded from Git and + remains in the original worktree. +- Merged that snapshot into upstream `62304dc85b3c1069cd656dfacb61ee820e216fa2` + with semantic conflict resolution. Plugin Marketplace Release A, Data Service, + AI Design Living Form V2, cloud-only Canvas, project identity, module access, + local voice input, conversation navigation, and the local `.makelore`-only + project-data boundary are all retained. +- Adopted one long-lived parent Pi Agent Server with isolated per-Conversation + Runtime, Session, credential store, extension context, generation and JSONL + channel. Thread configuration is supplied by a per-thread extension factory + closure rather than process-global environment mutation or late CLI flag + injection. Child Agents remain independent short-lived processes. +- Reconciled ADR-006, the decision index, architecture maps, domain rules, success + criteria, current-state memory, README, release runbook and tests with the new + topology and `.makelore` authority. Plugin selection now persists at + `.makelore/plugins.json`; no project metadata is read or migrated from + `.niancode` or `.opencode`. +- Promoted the streaming-preview behavior into the canonical README while leaving + its source task record unchanged on source snapshot `33fb31fb`; the Integration + Gate intentionally does not copy task-owned source records into the integration + result. +- Created two-parent merge commit + `28897cd4a2b7179d9ccb444218a1d74a2bbc004b` and fast-forwarded the occupied + local `main` to it. The original tracked source tree was hash-verified against + snapshot tree `925e515a` before moving the branch. The generated Learning player + remains locally present and ignored only through repository-local Git metadata; + no remote push was performed. + +## Verification + +- `pnpm install --frozen-lockfile` with exact `pnpm@10.33.4`: passed. +- `pnpm run typecheck`: passed. +- Merge-focused Vitest set: 14 files / 121 tests passed. +- `pnpm test`: 212 files / 1,726 tests passed; 3 staged-runtime-only tests skipped. +- `pnpm run lint:check`: passed with zero errors and five existing warnings + (`Home/index.tsx` hook dependency and four `Makelore/index.tsx` Fast Refresh warnings). +- `pnpm run build:vite`: passed for Renderer, Electron Main, Preload and utility worker; + existing dynamic-import and large-chunk warnings remain non-fatal. +- Focused `pnpm run test:e2e` for app smoke, main navigation, Pi first chat, + project configuration Skills and Coding project identity: 13/13 passed. +- `git diff --check`: passed; unresolved merge path count is zero. +- `check_project_docs.py` and task-aware `check_doc_drift.py`: passed after the + source task record remained on its source snapshot instead of being promoted. + +## Follow-ups + +- Existing release gates remain unchanged: official Marketplace Ed25519 trust-key + activation, real Provider validation, macOS x64/arm64 and native non-WSL Linux + packaged evidence are not promoted to Pass by this merge. + +## Promotion Candidates + +- Merge commit `28897cd4a2b7179d9ccb444218a1d74a2bbc004b` is present on local + `main`; no remote promotion was requested or performed. diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index 8cd9705..171904b 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -46,13 +46,13 @@ - 关闭的模块卡片必须置灰且无法点击;其根路由、深层路由和别名路由必须在 `MainLayout` 或模块初始化前阻断。Code provider 只能在 auth policy hydration 完成且 Code 已开启时初始化;`/settings` 是全局设置,不得随 Code 关闭而失去访问。 - 模块置灰/路由阻断不是 API 授权边界。每个 Works/模块服务端 API 仍必须独立执行身份与权限检查;`/api/auth/me` 返回终止性 `401` 时必须清理 Main 和 Renderer 会话,不得以默认全开继续。 - Makelore Code 的唯一 production runtime 是精确 pin 的 Pi `0.84.2`。不得恢复 OpenCode fallback、RPC/SDK 双轨、兼容执行路径或 Renderer runtime 直连;产品公共合同必须保持 project/Agent/Conversation/Snapshot/Patch 中立,Pi wire 只属于 Main。 -- project、Agent、Conversation 使用 schema v2。Agent id、名称、原始 prompt、Skills 与 archive 状态必须稳定保存;旧 v1 在写入前做时间戳备份,旧 OpenCode 会话迁为本地空 Conversation 且不继续运行。只处理 exact generated legacy Agent 文件,modified/unknown 文件先进入备份,其他 `.opencode` 内容不得被迁移顺带删除。 -- 每条 active/warm Conversation 绑定一个 persistent Pi worker/session。首次本地 Conversation 创建和 Composer 编辑不得等待 worker;未解析 Conversation 第一次选模必须先验证模型、持久化 resolved metadata,再 prepare。已解析且同账号的模型变化复用 target `set_model`,跨账号变化等 active run settled 后只重建目标 worker。 -- prompt、steer、follow-up、compact 等 mutation 必须先获得目标 Conversation 的 `202` acceptance/dedupe 结果。confirmation timeout 只表示 uncertain,不得自动重发,也不得释放 run permit、process ownership 或 Main background lease;迟到 success/failure/exit/abort 必须单调、exactly-once 收敛,并且 sibling Conversation 继续独立工作。 +- project、Agent、Conversation 使用 `.makelore/project.json` 与 `.makelore/conversations.json` schema v2。Agent id、名称、原始 prompt、Skills 与 archive 状态必须稳定保存;不得从 `.niancode` 或 `.opencode` 读取或迁移项目元数据,也不得作为顺带清理删除这些用户内容。 +- 每条 active/warm Conversation 在同一个长驻父 Agent Server 内绑定独立 Pi Runtime/Session/channel。首次本地 Conversation 创建和 Composer 编辑不得等待逻辑线程;未解析 Conversation 第一次选模必须先验证模型、持久化 resolved metadata,再 prepare。已解析且同账号的模型变化复用 target `set_model`,跨账号变化等 active run settled 后只重建目标逻辑线程。 +- prompt、steer、follow-up、compact 等 mutation 必须先获得目标 Conversation 的 `202` acceptance/dedupe 结果。confirmation timeout 只表示 uncertain,不得自动重发,也不得释放 run permit、Agent Server/child process ownership 或 Main background lease;迟到 success/failure/exit/abort 必须单调、exactly-once 收敛。线程级失败只影响目标 Conversation;整个 Agent Server 退出时所有旧父 channel 一起 fail closed,但 Main/Renderer 继续存活且下次恢复只启动一个新 Server。 - Renderer 只消费 Snapshot-first 与 `patch-batch` SSE。每条 Conversation 的 generation/seq 独立;stale generation 丢弃,gap/reconnect 只恢复目标 Snapshot 并应用严格连续的缓冲 tail,不重放 mutation,也不改变乐观消息的 UI identity。 -- top-level worker 并发上限 4、warm idle LRU 上限 4、child 并发上限 4,parent/child 共用 FIFO process budget 8。单次 subagent dispatch 最多 8 个 child 且禁止递归;coding child 与 parent 共用同项目 write lease,父 abort/crash/generation 失效必须清理所有 child、permit 与 process lease。 -- Provider Account、credential、custom header 和 proxy token 只可投影到选中 worker env,不能进入 argv、catalog、Renderer 或未选中 worker。确定性的 Works user-context 缺失必须使缓存 gateway credential 过期、fail fast 且不重放 mutation,并向产品投影固定的 `CODING_PROVIDER_AUTH_REQUIRED`;不能把它解释为 Pi worker 崩溃。 -- Provider/resource revision 在 idle worker 下一 prompt 前应用;running worker 使用当前 run snapshot,settled 后重建。同账号 refresh single-flight 且最多一次 auth refresh/reopen。真实 Provider 认证、endpoint/proxy/rate-limit、协议差异、真实并发和跨 worker 凭据隔离由用户明确豁免并接受风险,`realTurnVerified=false`,不得写成 Pass。 +- top-level 逻辑 turn 并发上限 4、warm idle logical-thread LRU 上限 8、child 并发上限 4,child 使用 FIFO process budget 8;shared parent 逻辑线程不各占一个 process lease。单次 subagent dispatch 最多 8 个 child 且禁止递归;coding child 与 parent 共用同项目 write lease,父 abort/crash/generation 失效必须清理所有 child、permit 与 process lease。 +- Provider Account、credential、custom header 和 proxy token 只可投影到选中父逻辑线程的内存 credential store 或选中 child 进程,不能进入 argv、catalog、Renderer 或 sibling thread。确定性的 Works user-context 缺失必须使缓存 gateway credential 过期、fail fast 且不重放 mutation,并向产品投影固定的 `CODING_PROVIDER_AUTH_REQUIRED`;不能把它解释为 Pi 崩溃。 +- Provider/resource revision 在 idle 逻辑线程下一 prompt 前应用;running 线程使用当前 run snapshot,settled 后重建。同账号 refresh single-flight 且最多一次 auth refresh/reopen。真实 Provider 认证、endpoint/proxy/rate-limit、协议差异、真实并发和共享 Agent Server 内跨线程凭据隔离由用户明确豁免并接受风险,`realTurnVerified=false`,不得写成 Pass。 - 客户端更新检查由 Electron Main 持有。缺少当前平台正式稳定 manifest 必须保持错误并提示稍后重试或从官网下载,不得宣称“已是最新版”;设置页只显示一条中文用户提示,原始堆栈、URL、路径和错误码只保留在 Main 日志。 - 一个 AI Design Workspace 公开一个 current Direction、一个 persistent Agent Session 和一个 Current Specification;conversation timeline 不是独立创建或选择的权威对象。 - Living Form 只能投影服务端 Current Specification。Chat、direct edit、decision、proposal、lock、Asset binding 与 restore 必须通过同一 V2 reducer;Renderer drafts 在 accepted 前保持本地。 @@ -102,4 +102,4 @@ ## Last Reviewed -2026-08-26 +2026-08-31 diff --git a/AGENTS.md b/AGENTS.md index 4293f9d..d2d2fae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,14 +26,14 @@ Use the exact pnpm version pinned by `packageManager` in `package.json`. - Do not add direct page/component IPC calls or direct local-runtime HTTP requests. - 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 V2 Workspace contract. One Workspace owns one persistent Living Form and Agent Session; Renderer chat and direct edits must use the same server reducer. Do not add a local semantic adapter or fallback. -- Preserve internal `niancode` compatibility identifiers unless a separately approved migration covers app id, protocol, storage, environment, headers, backend contracts, and installed-user data. +- Keep AI painting behind its Main-owned cloud V2 Workspace contract. One Workspace owns one persistent Living Form and Agent Session; Renderer chat and direct edits must use the same server reducer. The unpackaged local adapter is development-only and must never become a packaged fallback or semantic authority. +- 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 76e5bbf..c30e2fd 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)维护一份从创建起就存在的 Living Form。用户通过对话或直接编辑持续完善同一设计方向,再生成图片、单参考图作品或视频;参考素材可从当前项目作品选择或从本地上传。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,支持本地与公网开发地址。 @@ -94,12 +94,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 刷新、Workspace 所属的持久 Agent Session、稳定命令身份、有界 Run 查询、可恢复事件订阅与契约映射,并通过本机 Host API 的 SSE 投影同步表单、任务和资产状态。切换 Workspace 只重连对应流;注销或退出时关闭本地流,不删除服务端持久 Session。远端 Token、Provider Prompt 与存储地址不进入 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 是固定产品能力,不作为用户可增删的项目实体。 ### 共享开发浏览器 @@ -109,33 +109,34 @@ 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 +### 项目内置编码技能 -- 产品内置四个核心编码 Skill 根:`agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划),统一从 vendor-neutral 的 `resources/coding-skills/` 打包。`data-service`(开发数据)则由固定的 `resources/coding-plugins/data-service/` 插件包持有,不在核心 Skill 根中复制路径或定义。 -- 项目插件“启用”和把插件 Skill 分配给伙伴是两个独立动作:只有项目已启用 Data Service 时,未分配的 `data-service` 才可供新选择;禁用后,已有分配仍会显示并继续保存在 Agent 的 `skillIds`,但处于不可用且不生效的状态,重新启用后恢复生效。未选择的 Skill 不进入该 Agent 的有效 Pi 资源集合。 +- 产品内置四个核心编码技能根:`agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划),统一从 vendor-neutral 的 `resources/coding-skills/` 打包。`data-service`(开发数据)由固定的 `resources/coding-plugins/data-service/` 插件包持有,不在核心技能根中复制路径或定义。 +- 项目插件“启用”和把插件技能分配给智能体是两个独立动作:只有项目已启用 Data Service 时,未分配的 `data-service` 才可供新选择;禁用后,已有分配仍会显示并继续保存在智能体的 `skillIds`,但处于不可用且不生效的状态,重新启用后恢复生效。未启用的技能不进入该智能体的有效 Pi 资源集合。 - `data-service` 只在用户显式请求后触发:先检查并说明最小集合,用户确认后配置一次、复制 SDK 资产,再用本地预览执行 put/read-back;它不用于已发布作品。 -- 创建项目伙伴时,`agent-browser`、`grilling` 与 `planning-with-files` 默认勾选;`frontend-slides` 作为专项能力可手动选择。用户可以在创建或维护伙伴时调整选择。最终选择写入项目 Agent 的 `skillIds`。 -- `grilling` 会在复杂实现前逐项确认高影响决策,用户确认前不执行变更。`planning-with-files` 只在复杂、可分阶段或需要跨会话恢复的任务中使用,并把 `task_plan.md`、`findings.md` 和 `progress.md` 直接保存到当前项目根目录,不写入 Skill 安装目录、用户目录或 `.niancode/agent-planning/`。 +- 创建项目智能体时,`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 35100a8..542414c 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 { @@ -33,8 +35,10 @@ import { import { createCodingProductHost, type CodingProductComposition } from './coding-product-services'; import { createDataServiceOperations } from '../services/data-service-client'; import { createPreviewDataSessionManager, type PreviewDataSessionManager } from '../services/preview-data-session'; -import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader'; -import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1'; +import { + archivePiConversationSession, + getPiManagedPaths, +} from '../coding-runtime/pi/resource-loader'; import { createProjectPluginService } from '../coding-plugins/project-service'; import { createCodingCapabilityRegistry, @@ -71,6 +75,7 @@ import { export interface CodingCompositionPaths { executablePath: string; cliPath: string; + serverPath: string; userDataDir: string; bundledSkillsDir: string; } @@ -89,14 +94,51 @@ export interface CreateCodingCompositionOptions { policyClient?: PluginPolicyClient; } +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( @@ -107,6 +149,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'), }; } @@ -196,6 +241,12 @@ 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: [], @@ -204,12 +255,6 @@ export function createCodingComposition( let plugins: CodingProjectPluginService | undefined; let previewDataSession: PreviewDataSessionManager | undefined; const projects = new CodingProjectService(projectStore, { - migration: { - resolveLegacyModel: async ({ legacyModel }) => resolveLegacyProjectModel( - legacyModel, - await getProviderService().listAccounts(), - ), - }, createConversationStore: conversationStoreForProject, onResourcesChanged: async (project) => { runtime?.markResourcesStale(); @@ -308,6 +353,8 @@ export function createCodingComposition( }); const workerPool = new PiWorkerPool({ processBudget, + processMode: 'shared', + maxIdle: 8, revisionCoordinator: revisions, openWorker: createPiManagedWorkerOpener({ registry, @@ -321,6 +368,7 @@ export function createCodingComposition( registerActivePluginReleases, loadProviderInput, resolveCredential: resolvePiProviderCredentialFromSecretStore, + createProcess: (processOptions) => agentServer.createWorker(processOptions), ...(getLocalProxyCredential ? { getLocalProxyCredential: async () => getLocalProxyCredential() } : {}), @@ -349,7 +397,6 @@ export function createCodingComposition( const subagents = new PiSubagentScheduler({ openChild: childOpener, processBudget, - reclaimProcessCapacity: (signal) => workerPool.reclaimIdleWorker(signal), }); runtime = new PiConversationRuntime({ pool: workerPool, @@ -443,6 +490,7 @@ export function createCodingComposition( await Promise.allSettled(conversationIds.map((conversationId) => ( runtime.dispose(conversationId, reason) ))); + await agentServer.stop(); }, async shutdown() { previewDataSession?.dispose(); @@ -456,9 +504,13 @@ export function createCodingComposition( await subagents.close(); await runtime.shutdown(); } finally { - unsubscribeMarketplaceSession(); - packageStore.dispose(); - marketplaceClient.dispose(); + try { + await agentServer.stop(); + } finally { + unsubscribeMarketplaceSession(); + packageStore.dispose(); + marketplaceClient.dispose(); + } } }, }; 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 7fe6d5a..53cf170 100644 --- a/electron/api/routes/coding-projects.ts +++ b/electron/api/routes/coding-projects.ts @@ -146,16 +146,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-plugins/project-service.ts b/electron/coding-plugins/project-service.ts index e14d2c2..3873352 100644 --- a/electron/coding-plugins/project-service.ts +++ b/electron/coding-plugins/project-service.ts @@ -5,7 +5,7 @@ import { DATA_SERVICE_PLUGIN_ID, } from '../../shared/coding-plugins'; -export const PROJECT_PLUGIN_SELECTION_PATH = '.niancode/plugins.json'; +export const PROJECT_PLUGIN_SELECTION_PATH = '.makelore/plugins.json'; export const PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION = 1 as const; const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u; 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 cfb03aa..0172f94 100644 --- a/electron/coding-projects/project-config.ts +++ b/electron/coding-projects/project-config.ts @@ -18,15 +18,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 = @@ -210,9 +208,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'); @@ -225,7 +220,6 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon initialized: record.initialized === true, agents, knowledgeDirectory: 'knowledge', - legacyConversationNotice: record.legacyConversationNotice as LegacyConversationNotice, createdAt, updatedAt, }; @@ -246,7 +240,6 @@ export function createCodingProjectConfigV2( initialized: false, agents: [], knowledgeDirectory: 'knowledge', - legacyConversationNotice: 'none', createdAt: now, updatedAt: now, }; @@ -293,7 +286,7 @@ export async function createCodingProjectMetadata( } const config = createCodingProjectConfigV2(options.now, options.projectType, options.projectId); 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); @@ -330,17 +323,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 711faff..45f4ee0 100644 --- a/electron/coding-projects/project-service.ts +++ b/electron/coding-projects/project-service.ts @@ -8,7 +8,6 @@ import { type CodingConversationV2, } from './conversation-store'; import { - acknowledgeLegacyConversationNotice, isCanonicalCodingProjectId, normalizeCodingProjectConfigV2, normalizeProjectIdentityChoice, @@ -16,10 +15,6 @@ import { writeCodingProjectConfigV2, type CodingProjectConfigV2, } from './project-config'; -import { - migrateCodingProjectToV2, - type CodingProjectMigrationDependencies, -} from './migration'; import { createLocalCodingProject, normalizeCodingProjectPath, @@ -79,7 +74,6 @@ export interface CodingProjectServiceOptions { ): Promise | void; createConversationStore?: typeof createCodingConversationStore; writeConfig?: typeof writeCodingProjectConfigV2; - migration?: CodingProjectMigrationDependencies; } function requiredAbsolutePath(value: string | undefined, label: string): string { @@ -147,7 +141,6 @@ export class CodingProjectService { ReturnType >(); private activeTransitionTail = Promise.resolve(); - private readonly migrationFlights = new Map>(); private readonly identityTransitionTails = new Map>(); constructor( @@ -508,16 +501,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; @@ -569,19 +552,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 4514480..649d4ec 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 1d76e53..39d49d5 100644 --- a/electron/coding-projects/skill-registry.ts +++ b/electron/coding-projects/skill-registry.ts @@ -104,8 +104,11 @@ export async function listProductCodingSkills( ]; const visibleSources = sources.filter(({ id, available }) => available !== false || selected.has(id)); return await Promise.all(visibleSources.map(async ({ id, directory, entryPath, available = true }) => { - const location = path.resolve(directory); - const content = await readFile(path.join(location, entryPath ?? 'SKILL.md'), 'utf8'); + const sourceLocation = path.resolve(directory); + const location = (BUNDLED_CODING_SKILL_IDS as readonly string[]).includes(id) + ? path.posix.join('resources', 'coding-skills', id) + : sourceLocation; + const content = await readFile(path.join(sourceLocation, entryPath ?? 'SKILL.md'), 'utf8'); return { id: productSkillId(id), name: frontmatterScalar(content, 'name') ?? id, @@ -115,7 +118,7 @@ export async function listProductCodingSkills( effective: available && selected.has(id), location, content, - entries: await listSkillEntries(location), + entries: await listSkillEntries(sourceLocation), }; })); } 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 39a1e92..ee480b0 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -269,6 +269,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], allowedToolNames: [...allowedToolNames], diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts index 1a14ace..25718df 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` @@ -13,167 +13,217 @@ const MUTATION_TOOLS = new Set([ 'edit', 'write', ]); -const WORKER_ROLE = process.env.MAKELORE_PI_WORKER_ROLE || 'parent'; -const leases = new Map(); -const touchedPaths = new Map(); -let dynamicLeaseTools = new Set(); +const RUNTIME_FLAGS = { + bridgeUrl: 'makelore-bridge-url', + workerToken: 'makelore-worker-token', + contextFile: 'makelore-context-file', + workerRole: 'makelore-worker-role', + projectPath: 'makelore-project-path', +}; -async function readWorkerContext() { - const value = JSON.parse(await readFile(process.env.MAKELORE_PI_CONTEXT_FILE, 'utf8')); - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Makelore worker context is invalid'); - } - return value; -} - -async function runtimeContext() { - const value = await readWorkerContext(); - 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'); +export function createMakeloreRuntime(runtimeDefaults = {}) { + return async function makeloreRuntime(pi) { + const fallbackFlagValues = new Map(); + const registerRuntimeFlag = (name, defaultValue) => { + fallbackFlagValues.set(name, defaultValue); + if (typeof pi.registerFlag === 'function') { + pi.registerFlag(name, { type: 'string', default: defaultValue }); } - if (done) break; + }; + registerRuntimeFlag( + RUNTIME_FLAGS.bridgeUrl, + runtimeDefaults.bridgeUrl || process.env.MAKELORE_PI_BRIDGE_URL || '', + ); + registerRuntimeFlag( + RUNTIME_FLAGS.workerToken, + runtimeDefaults.workerToken || process.env.MAKELORE_PI_WORKER_TOKEN || '', + ); + registerRuntimeFlag( + RUNTIME_FLAGS.contextFile, + runtimeDefaults.contextFile || process.env.MAKELORE_PI_CONTEXT_FILE || '', + ); + registerRuntimeFlag( + RUNTIME_FLAGS.workerRole, + runtimeDefaults.workerRole || process.env.MAKELORE_PI_WORKER_ROLE || 'parent', + ); + registerRuntimeFlag( + RUNTIME_FLAGS.projectPath, + runtimeDefaults.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(); + let dynamicLeaseTools = new Set(); + + async function readWorkerContext() { + 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 || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Makelore worker context is invalid'); + } + return value; } - 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 runtimeContext() { + const value = await readWorkerContext(); + if (!value.runId) throw new Error('Makelore run context is unavailable'); + return value; + } -async function releaseAll() { - await Promise.all([...leases.keys()].map(releaseLease)); - touchedPaths.clear(); -} + 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; + } -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 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 invokeProduct(toolCallId, toolName, input, signal) { - const response = await bridge('product.invoke', { - resourceId: toolCallId, - toolName, - input, - }, signal); - return response.result; -} + async function releaseLease(toolCallId) { + const leaseId = leases.get(toolCallId); + if (!leaseId) return; + leases.delete(toolCallId); + await bridge('lease.release', { leaseId, resourceId: toolCallId }).catch(() => undefined); + } -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); - }, - }); -} + async function releaseAll() { + await Promise.all([...leases.keys()].map(releaseLease)); + touchedPaths.clear(); + } -function isRecord(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} + 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('/'); + } -function isToolDeclaration(value) { - return isRecord(value) - && typeof value.name === 'string' - && typeof value.label === 'string' - && typeof value.description === 'string' - && isRecord(value.inputSchema); -} + async function invokeProduct(toolCallId, toolName, input, signal) { + const response = await bridge('product.invoke', { + resourceId: toolCallId, + toolName, + input, + }, signal); + return response.result; + } -async function registerDynamicProductTools(pi) { - if (WORKER_ROLE !== 'parent') return; - const context = await readWorkerContext(); - const allowedToolNames = new Set( - Array.isArray(context.allowedToolNames) - ? context.allowedToolNames.filter((name) => typeof name === 'string') - : [], - ); - const declarations = Array.isArray(context.tools) ? context.tools : []; - dynamicLeaseTools = new Set( - declarations - .filter((declaration) => isToolDeclaration(declaration) - && declaration.projectWriteLease === true - && allowedToolNames.has(declaration.name)) - .map((declaration) => declaration.name), - ); - for (const declaration of declarations) { - if (!isToolDeclaration(declaration) || !allowedToolNames.has(declaration.name)) continue; - registerProductTool( - pi, - declaration.name, - declaration.label, - declaration.description, - declaration.inputSchema, + function registerProductTool(name, label, description, parameters) { + pi.registerTool({ + name, + label, + description, + parameters, + async execute(toolCallId, params, signal) { + return await invokeProduct(toolCallId, name, params, signal); + }, + }); + } + + function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); + } + + function isToolDeclaration(value) { + return isRecord(value) + && typeof value.name === 'string' + && typeof value.label === 'string' + && typeof value.description === 'string' + && isRecord(value.inputSchema); + } + + async function registerDynamicProductTools() { + if (workerRole !== 'parent') return; + const context = await readWorkerContext(); + const allowedToolNames = new Set( + Array.isArray(context.allowedToolNames) + ? context.allowedToolNames.filter((name) => typeof name === 'string') + : [], ); + const declarations = Array.isArray(context.tools) ? context.tools : []; + dynamicLeaseTools = new Set( + declarations + .filter((declaration) => isToolDeclaration(declaration) + && declaration.projectWriteLease === true + && allowedToolNames.has(declaration.name)) + .map((declaration) => declaration.name), + ); + for (const declaration of declarations) { + if (!isToolDeclaration(declaration) || !allowedToolNames.has(declaration.name)) continue; + registerProductTool( + declaration.name, + declaration.label, + declaration.description, + declaration.inputSchema, + ); + } } -} -export default async function makeloreRuntime(pi) { - if (WORKER_ROLE === 'parent') pi.registerTool({ + if (workerRole === 'parent') pi.registerTool({ name: 'ask_user', label: 'Ask user', description: 'Ask the user for a selection, confirmation, short input, or editor text.', @@ -206,7 +256,7 @@ export default async 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.', @@ -252,8 +302,7 @@ export default async 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.', @@ -270,8 +319,7 @@ export default async 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.', @@ -291,8 +339,7 @@ export default async 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.', @@ -304,15 +351,14 @@ export default async 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.', { type: 'object', additionalProperties: false, properties: {} }, ); - await registerDynamicProductTools(pi); + await registerDynamicProductTools(); pi.on('tool_call', async (event, ctx) => { if (!MUTATION_TOOLS.has(event.toolName) && !dynamicLeaseTools.has(event.toolName)) return; @@ -344,7 +390,10 @@ export default async function makeloreRuntime(pi) { }); pi.on('agent_end', releaseAll); pi.on('session_shutdown', releaseAll); + }; } + +export default createMakeloreRuntime(); `; export async function materializeMakelorePiExtension(extensionsDir: string): Promise { 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 34a0af7..e2e7117 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', @@ -1367,6 +1384,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); } @@ -1398,7 +1416,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 ec413a7..56bc7f9 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -507,7 +507,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, }, }; @@ -515,6 +515,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) { @@ -524,7 +535,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, }; } @@ -545,7 +556,7 @@ function runtimeFailure(error: unknown): CodingRuntimePublicError { } return { code: 'CODING_RUNTIME_START_FAILED', - message: 'The local Agent is unavailable', + message: '本地 Agent 服务暂不可用。', recoverable: true, }; } @@ -639,7 +650,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), @@ -677,7 +691,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 { @@ -1464,6 +1481,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', }, @@ -1624,6 +1643,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 94a1677..250167d 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'; @@ -128,6 +129,9 @@ export const PI_CORE_TOOL_NAMES = Object.freeze([ 'task_state', 'changed_file', 'runtime_context', ] as const); +/** Compatibility alias used by the shared Agent Server transport. */ +export const DEFAULT_PI_RPC_TOOLS = PI_CORE_TOOL_NAMES; + export function buildPiRpcArgs( sessionDir: string, additionalArgs: readonly string[] = [], @@ -164,6 +168,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 = {}, @@ -265,8 +279,7 @@ export class PiWorkerProcess { DEFAULT_DIAGNOSTIC_BYTES, 'diagnosticBytes', ); - this.diagnosticSensitiveValues = [...new Set([ - ...(options.sensitiveValues ?? []), + const diagnosticPaths = [ options.cwd, options.cliPath, options.configDir, @@ -275,6 +288,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)))]; } @@ -472,7 +489,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 2da908c..62e401e 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -15,6 +15,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..ffbb142 --- /dev/null +++ b/resources/pi-agent-server.mjs @@ -0,0 +1,712 @@ +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 runtimeExtensionDefaults(options) { + const environment = record(options.env) ?? {}; + const value = (key, fallback = '') => ( + typeof environment[key] === 'string' && environment[key] + ? environment[key] + : fallback + ); + return { + bridgeUrl: value('MAKELORE_PI_BRIDGE_URL'), + workerToken: value('MAKELORE_PI_WORKER_TOKEN'), + contextFile: value('MAKELORE_PI_CONTEXT_FILE'), + workerRole: value('MAKELORE_PI_WORKER_ROLE', 'parent'), + projectPath: value('MAKELORE_PI_PROJECT_PATH', options.cwd), + }; +} + +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 extensionModule = await import(pathToFileURL(extensionPaths[0]).href); + if (typeof extensionModule.createMakeloreRuntime !== 'function') { + throw new Error('Makelore runtime extension factory is unavailable'); + } + 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, + resourceLoaderOptions: { + extensionFactories: [{ + name: 'makelore-runtime', + factory: extensionModule.createMakeloreRuntime(runtimeExtensionDefaults({ + ...options, + cwd: runtimeCwd, + })), + }], + 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 dd3ebe0..24dc7c7 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -31,6 +31,7 @@ function copyPublishRuntime(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 43ba082..627494f 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -678,8 +678,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}`); } @@ -761,6 +762,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 e5ea183..b2f1fce 100644 --- a/shared/coding-project-contracts.ts +++ b/shared/coding-project-contracts.ts @@ -37,7 +37,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 a8547ad..25f555a 100644 --- a/src/components/coding/AgentCreationDialog.tsx +++ b/src/components/coding/AgentCreationDialog.tsx @@ -123,7 +123,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); } @@ -141,11 +141,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; @@ -165,35 +165,35 @@ 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" />
-

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

-