diff --git a/CHANGELOG.md b/CHANGELOG.md index c10522f..1e82e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.15.0 (unreleased) +- Add an optional Skill-managed session using the existing MCP client SDK and a persistent interactive terminal. Start the Gateway on the first tool call, validate workspace/build/provider, reuse the connection and Roslyn snapshot, preserve complete MCP results as local JSON/image attachments, and close owned resources explicitly or on owner exit. Disable the client's native WinCode MCP entry to prevent its automatic startup; no automatic retry/reconnection, new service or dependency is introduced. - Preserve completed/partial trash outcomes and actual file locations when the request deadline expires during metadata finalization. Propagate cancellation before the move and keep interruption accounting; client cancellation or disconnection still does not guarantee delivery of a final response. - Add optional Roslyn reference `limit` (1–1000, default 100, requires `symbolLocation`). Impact reports retain `totalReferences` and `referencesTruncated` alongside the existing returned `referencesCount`; unknown totals remain null and semantic risk/coverage rules stay unchanged. - Bound reference-tool replies by final formatted JSON size with `maxOutputChars` (2048–32768, default 8000). Preserve known totals and exact identities, report returned counts and omitted lists, and reject budgets that cannot hold required metadata. The output limit covers both providers and does not bound Roslyn search work or change internal impact aggregation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35aa5f2..886d248 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,8 @@ Reports and bounded stage logs are under `test-tmp/check//`. CI retains the `npm run test:roslyn-host` and `npm run test:roslyn-gateway` use generated C# projects and an already installed SDK selected by `scripts/lib/dotnet.mjs`. The gateway check copies the entire published Code Host into a Chinese path with spaces and checks real overloads, stale identities and owned MSBuild descendants. This is not a clean-machine test or verification of the current Codex connection. +`tests/skill-session.test.ts` covers the optional persistent Skill client in three scenarios: no Gateway before a call, connection/result preservation, real Roslyn snapshot reuse and invalidation, and cancellation followed by owner death during actual MSBuild work. It uses generated projects with offline restore against the installed SDK and observes process IDs plus creation times. Codex terminal integration must also use `tty:true` and read the result-file receipts; a pipes-only test does not prove that workflow or a refreshed desktop configuration. + Windows CI jobs have a 20-minute overall budget for cold setup and native acceptance. Node 22 runs shared-cache checks early, then gives SDK concurrency and design-time output ownership separate steps. Design-time readiness uses the production Roslyn load budget of 120 seconds; it does not impose an additional cold-start performance target. Production request deadlines remain unchanged. `npm run test:owner-death` kills only a generated Gateway during confirmed initial MSBuild work and checks all previously observed process identities for survivors. `node scripts/verify-owner-death.mjs --repomix` audits the actual adapter with a controlled Node CLI, without installing Repomix. `check:desktop` also runs the `--desktop` owner-death scenario against the isolated WPF fixture: the UIA Helper must exit while the target remains alive; the fixture is closed separately after recording that result. Reports are kept under `test-tmp/owner-death/`. These checks never identify client applications by process name. diff --git a/README.md b/README.md index 7b16a98..2f09f5e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ Running `npm run check` builds the Gateway and native components, runs core regr **2. Configure the MCP connection** +For Codex with persistent terminal support, the [Skill on-demand mode](skills/wincode/references/diagnostics.md#skill-按需会话) starts WinCode only when needed and reuses one connection throughout the task. Install the Skill, disable the native WinCode MCP entry, and refresh the client connection before using this mode. The Skill starts `dist/Client/SkillSessionCli.js` in an interactive execution session; results remain available as full JSON and image files. It requires an extra file read per result. Other clients can use the native stdio configuration below. + For clients that support `mcpServers`, add the following stdio configuration. Explicitly setting `--workspace` is recommended: ```json @@ -242,6 +244,8 @@ npm run delivery:verify **2. 配置 MCP 连接** +支持持久终端的 Codex 可使用 [Skill 按需模式](skills/wincode/references/diagnostics.md#skill-按需会话):首次需要时才启动 WinCode,任务内复用同一连接。先安装 Skill、禁用原生 WinCode MCP 条目,并刷新客户端连接。Skill 通过交互执行会话启动 `dist/Client/SkillSessionCli.js`,完整结果保存为 JSON 和图片文件,每次结果需要额外读取文件。其他客户端可使用下列原生 stdio 配置。 + 对于支持 `mcpServers` 的客户端,添加以下 stdio 配置。建议显式设置 `--workspace`: ```json diff --git "a/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" "b/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" index 7f6844e..00d20ee 100644 --- "a/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" +++ "b/WinCode-Skill\345\210\266\344\275\234\344\270\216MCP\351\205\215\347\275\256\346\214\207\345\215\227.md" @@ -1,10 +1,10 @@ # WinCode Skill 安装、维护与 MCP 配置指南 -适用于 **0.15.0**,核对日期 2026-09-10(北京时间)。以下使用本机 `I:/WinCode` 路径举例;其他机器必须替换路径。客户端界面名称随版本变化,以实际界面为准。 +适用于 **0.15.0**,核对日期 2026-09-12(北京时间)。以下使用本机 `I:/WinCode` 路径举例;其他机器必须替换路径。客户端界面名称随版本变化,以实际界面为准。 ## 1. 三个独立对象 -- **Skill 手册**指导 Agent 选择工具和使用规范字段,不启动服务器。 +- **Skill 手册**指导 Agent 选择工具和使用规范字段;被发现或读取不启动服务器。按需模式由 Agent 首次需要时执行随产品交付的会话入口。 - **磁盘交付物**包含 Gateway、原生 UIA/Code Host 及可选 Tray 的完整发布目录、构建身份和交付清单。 - **MCP 连接实例**是客户端已经启动的进程;更新源码、构建或复制 Skill 都不会自动更新这个进程。 @@ -42,7 +42,17 @@ npm run skill:check -- C:/Users/40218/.agents/skills/wincode **未知字段保持容忍,但不会生效。** 参数名称、大小写、类型和范围以手册字段表为准。例如 `automationId` 是规范字段,`automationID` 不会成为筛选条件;仅含未知字段的 query 仍缺少必需条件。适配器配置字段不能伪装成 MCP 请求参数。 -## 4. 注册 stdio MCP +## 4. 选择启动方式 + +### Codex 按需模式 + +先构建并同步 Skill,再按[会话手册](skills/wincode/references/diagnostics.md#skill-按需会话)用持久交互终端验收连续查询和关闭。通过后,在 Codex `config.toml` 原有 `[mcp_servers.wincode]` 段内设 `enabled = false`,保留 `command/args` 等配置,刷新客户端连接;已运行进程不会因保存配置自行消失。禁用原生条目后,Codex 启动不再因该条目创建 WinCode。 + +Skill 首次使用启动 `dist/Client/SkillSessionCli.js --workspace <当前项目绝对路径>`;终端须保留 stdin(Codex `exec_command` 使用 `tty:true`)。任务内复用返回的执行会话 ID,工具参数不变,完整结果通过回执中的文件读取。任务收尾显式关闭;不会每次查询重启 Host,也没有额外常驻服务或自动重连。Roslyn 配置须明确匹配目标项目。恢复原生模式时关闭按需会话,把原配置的 `enabled` 恢复为 `true` 后刷新客户端。 + +此模式依赖客户端执行工具能保留交互会话;不支持时使用下方原生配置。结果文件包含实际源码/截图,保存在安装目录 `test-tmp/skill-sessions/`,不会随连接关闭自动删除。Skill 同步脚本不会替用户切换模式。 + +### 原生 stdio MCP 在客户端添加 stdio 服务器,分别填写: diff --git "a/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" "b/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" index 67f341e..e412059 100644 --- "a/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" +++ "b/WinCode-\344\270\213\344\270\200\350\275\256\345\267\245\347\250\213\345\214\226\350\277\255\344\273\243\350\256\241\345\210\222\344\271\246.md" @@ -1,6 +1,6 @@ # WinCode 后续测试计划 -更新:2026-09-11(北京时间)。当前版本为 **0.15.0**,已验证的 main 基线为 `631f8ba`。版本和 CI 状态见 [README](README.md),实现方式见[架构说明](WinCode-架构与数据流说明.md),历史结果见[工作日志](docs/codex_worklog.md)。 +更新:2026-09-12(北京时间)。当前版本为 **0.15.0**;按需会话改动基于 `af9c1b6`。版本和 CI 状态见 [README](README.md),实现方式见[架构说明](WinCode-架构与数据流说明.md),历史结果见[工作日志](docs/codex_worklog.md)。 固定工作区、保持已加载的 Host、请求数量限制、独立的设计时输出目录、共享缓存回归,以及 PR #40/#41 的代码导航、UI 精简输出和测试修正已合并,Node 22/24 和 CodeQL 检查通过。后续本地验收已完成 TavernDesk 的 8 个上下文场景、6 个 UI 产品任务、两个独立 Gateway 的审计争用及恢复,以及实际 Codex 连接中的单项目 Roslyn 流程。对应 PR 的交付状态见工作日志;这些测试完成项从待办移除,不扩大为所有项目或所有 UI 环境均已验证。 @@ -8,6 +8,8 @@ | 事项 | 已知情况 | 下一步 | | --- | --- | --- | +| 已安装 Codex 的冷启动确认 | Skill 按需入口已通过真实终端连续 Roslyn 查询、关闭和所属进程退出;Node 22.23.2 与 24.19.0 本地核心回归均为 462 通过、1 项可选夹具跳过。Node 24 已在 I:/New-tarven 完成后台 UI 和真实图片附件验证,Node 22 补验了该项目的实际终端声明/引用流程。原生 wincode 已设 enabled=false,已安装 Skill 四文件一致 | 正常刷新/重新启动 Codex,确认未调用时没有新建 WinCode 进程。当前任务未重启宿主;保存配置不会清除旧连接。Node 22 证据见 test-tmp/node22-validation/report.json | +| Tavern 的 Roslyn 语义覆盖 | 本地 App 项目 Release/net10.0-windows 快照正确返回导航声明及两处引用,并跨 UI 调用保持身份;同时排除 24 个分析器/生成器,报告 CS8795 和部分 Microsoft.Data/Microsoft.ML 的 CS0234。源码正常构建/隔离启动不能消除这些设计时诊断 | 如需扩大到完整项目语义,先核对设计时项目引用、TFM 和依赖解析,再区分生成器策略造成的缺口;当前维持 incomplete,不根据局部零引用判断删除安全。证据:test-tmp/tavern-skill-JNZ7Wu/report.json | | UI 和托盘的间歇性失败 | PR #41 将真实端点启动检查移到顺序阶段,main 两个 Node 版本均通过,3000 ms 生产超时未变;隐藏/唤出、截图/提示窗及原生启动延迟的根因仍未全部定位 | 再现时保存错误码、原生日志和步骤耗时,再针对原因修复;不把调度修正及重跑通过称为已解决所有 UI/托盘问题 | | SDK 突发请求警告 | main 的 128 请求阶段仍记录 11 个 drain 监听器警告,阶段结束后为 0;此次实际客户端连续查询未出现调用失败,但未采集该客户端的监听器统计 | 若普通负载也出现问题,再检查发送背压及监听器释放。不提高监听器上限来隐藏警告,也不据此认定内存泄漏 | diff --git "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" index 2b35cbc..384e82a 100644 --- "a/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" +++ "b/WinCode-\346\236\266\346\236\204\344\270\216\346\225\260\346\215\256\346\265\201\350\257\264\346\230\216.md" @@ -41,9 +41,12 @@ flowchart TB 图中箭头表示主要调用或数据联系,不表示每条请求都经过全部组件。原生 Host 的进程隔离用于故障与生命周期控制,**不等于操作系统安全沙盒**。 +Codex 可选择 Skill 按需入口:首次使用才创建交互执行会话,`SkillSessionCli` 使用现有 MCP SDK 连接上图中的 Gateway,后续查询复用同一连接。入口启动或读取本地状态不会创建 Gateway;首次工具请求完成工作区、构建和提供方核验后才派发业务。完整 MCP 结果和原始图片作为本地附件交付,入口不重写工具契约或自动重放请求。关闭/所属入口死亡沿用 Gateway 与原生 Host 的清理链路。该模式需禁用客户端自动启动的 WinCode 条目,不能通过 Gateway 内部延迟初始化实现客户端侧零进程;使用方法见 [Skill 会话手册](skills/wincode/references/diagnostics.md#skill-按需会话)。 + | 层 / 模块 | 负责什么 | 设计边界与源码入口 | |---|---|---| | 启动层 | 解析 workspace/development 参数,创建 Router 和 MCP Server,处理退出 | [index.ts](src/index.ts);当前入口以默认配置和 CLI 参数启动,不是通用配置中心 | +| 可选 Skill 客户端 | 按需连接、任务内复用、附件交付与显式关闭 | [SkillSession](src/Client/SkillSession.ts)、[SkillSessionCli](src/Client/SkillSessionCli.ts);运行于持久执行会话,不注册第二个 MCP 服务器或常驻服务 | | Gateway | 列举工具、校验输入、执行工具、封装结果 | [McpServer](src/Gateway/McpServer.ts)、[ToolRegistry](src/Gateway/ToolRegistry.ts);不直接调用适配器字段 | | ToolRouter | 创建并组合组件,提供用例入口,协调请求与工作区生命周期 | [ToolRouter](src/Core/ToolRouter.ts);这是装配与协调中心,不只是名称路由表 | | 核心能力契约 | 定义符号、引用、打包、UI、操作取消等数据类型 | [CodeQueries](src/Core/CodeQueries.ts)、[ContextPacking](src/Core/ContextPacking.ts)、[UiContracts](src/Core/UiContracts.ts)、[OperationContext](src/Core/OperationContext.ts) | diff --git a/docs/codex_worklog.md b/docs/codex_worklog.md index e2f6c21..693e996 100644 --- a/docs/codex_worklog.md +++ b/docs/codex_worklog.md @@ -1391,3 +1391,42 @@ - 调整中文语序、动宾搭配和链接间距,同步英文。依据微软文档使用“MSBuild 项目评估”,将 rollForward=disable 解释为 SDK 版本须完全匹配;字符串搜索和 Skill 同步说明保持现有接口含义。Computer Use 比较保留为日常开发体验,222 节点窗口的 62 KB/1.6 KB 数据来自既有测试记录。 - 文档验证通过:10 个本地链接、6 个 JSON 示例、4 个工具调用示例的当前源码 Schema 校验、7 个 npm 命令和 16 个工具名称;中英文工具列表与对应配置一致,git diff --check 通过。未发现文档验证失败;没有修改生产代码、依赖或受管 Skill,也没有新增测试或重新运行运行时测试。 - 本条按 CONTRIBUTING 的合并要求追加。最新提交的 Node 22/24 与三项 CodeQL 仍待远端检查;本地自查不等于独立审查或活动客户端验收。 + +## 2026-09-12 08:57 — 同步云端 main、本地构建及已安装 Skill(北京时间) + +- 用户要求以较新的云端版本对齐本地,并同步 Skill、评估是否需要更新设计。同步前无未提交修改;获取 origin 后确认 PR #37 已于 2026-09-11 合并。保留原检查点分支,切换 main 并快进到 `af9c1b6a7284cf8fa909badedcc1c05a34cc7ee1`,与 origin/main 一致。该提交远端 CI、Push on main 和 Scheduled 均已成功,分别见运行 34615593278、34615592254、34618221513。 +- 本地原 dist 仍为 2026-09-10 构建,不能仅以同为 0.15.0 判断已更新。使用现有依赖和 SDK 10.0.303 完成 typecheck、Gateway 及 UIA/Code Host/Tray 的标准 Release 发布(--no-restore),未安装或升级依赖。新 buildId=`6fa5431f5e5903928848bc48abb02ac97f9cab259bf8f5b8c3efc958c428f370`;生成交付清单并验证 matched=true,contentId=`081048e0f13e3ea31c24accf1f965d3dc5950f85bf2d2cf672d41258f44b7da0`。 +- 已安装 Skill `C:/Users/40218/.agents/skills/wincode` 从 0.13.1 同步至仓库 0.15.0;使用既有 sync-skill 脚本,先检查、完成备份,再覆盖四份受管文档。备份为 `C:/Users/40218/.agents/skills/.wincode-backup-ba6f56a7-1d51-448b-a837-e2d50fd5d872`;同步后二次检查及四文件 SHA-256 均一致。未发现 `.codex/skills/wincode` 安装副本,没有新建重复安装或改动 MCP 配置。 +- Skill 设计判断:云端已更新固定工作区、限定范围搜索/文件概览、context 续读、UI compact/展开、引用条数与最终字符预算、移动结果及错误恢复规则。继续采用短 SKILL.md 入口加三份按需手册,无需本轮额外改结构或加入抽象层;安装同步已经覆盖旧版指令差异。 +- 实际验证:Skill 同步测试 2/2;工具契约、运行时契约及交付契约测试 30/30;生产 stdio 新连接通过,17 个工具,schemaHash=`6db24ea89d53ad793d7e63e72df418ab0dc031ec651fd93f4dd56708a90e5efb`,覆盖新搜索/概览/精确上下文及边界响应。本地回执为 `test-tmp/sync-20260912/stdio.json`。未重复完整 460 项、桌面或真实 Roslyn 语义验收,构建通过不代替这些流程。 +- 反证检查:当前 Codex 已连接实例 `605af938-fa1c-49d3-b8fc-fbe165a5fd1d` 实际仍为旧 buildId `be08ba26...`、15 工具,并固定在历史 client-roslyn 测试根;磁盘重建和 Skill 同步不会热替换它。需客户端刷新/重启连接后核对新 buildId 与目标工作区;本轮未强杀该进程或擅自改变绑定项目。源码与远端提交已对齐,只有本条本地工作记录为额外修改,未提交或推送。 + +## 2026-09-12 16:54 — 实现 Skill 按需会话并切换本机启动配置(北京时间) + +- 用户批准开始实现首次使用前不随 Codex 启动的方案。基于 `af9c1b6` 新建 `codex/skill-on-demand-session`,保留本文件此前的同步记录;未提交或推送。新增 `src/Client/SkillSession.ts` 和 `SkillSessionCli.ts`,使用已安装的 MCP SDK 2.0.0;没有新依赖、常驻服务、监听端口、Gateway/Host 生命周期重构或公共 MCP 参数变化。 +- 入口由 Skill 首次需要时通过持久交互终端启动;对象构造、入口 ready 和本地 status 不创建 Gateway。首次工具调用连接并核验工作区、buildId 和 provider,多次调用共享连接、Host 与有效 Roslyn 快照。任务收尾显式关闭;连接失败不自动重连或重放。沿用 Gateway EOF 与原生所属进程清理,SDK 发出终止后额外等待实际传输退出。 +- 原计划使用 Node REPL 承载 SDK,实际导入被该工具禁止的 `node:process` 拒绝,因此改用现有 `exec_command(tty:true)`/`write_stdin`;没有绕过限制。普通执行管道会立即 EOF,TTY 输出又可能折行,所以完整 MCP 结果写入 `test-tmp/skill-sessions/run-*/`,图片保留原始数据和解码文件,终端仅返回路径回执。读取结果增加一次文件读取;附件不自动删除。传输失败与已收到结果后的附件交付失败分别报告,均不自动重放业务。 +- 新增恰好 3 个自动测试:未调用状态与连接/结果保持、真实 Roslyn 快照复用和修改后的旧定位拒绝、真实 MSBuild 阻塞时取消并结束入口的所属进程回收。首次进程数量断言把 Windows 创建的 conhost 当成 Gateway,多出的进程经实际命令行核对后修正断言;仍禁止未调用时创建任何 Gateway/业务子进程。三项在最终完整回归中全部通过。 +- 使用当前 Codex 真实交互终端,在隔离 C# 夹具完成搜索声明与后续引用查询。Gateway PID=12376、instanceId=`0da9d0dd-f978-4071-88bc-6c894471969e` 在两次调用间一致,snapshotId=`b32ac81643384f98ad24872d5460e4d9` 直接复用,返回 1 处已知引用。显式关闭后,按 PID/创建时间核对的 5 个所属进程均退出;报告 `test-tmp/skill-session-live/report.json`。这是实际执行工具路径验证,未冒充刷新后的 Codex 原生连接验收。 +- Node 24.19.0 完整 `npm run check` 通过:463 项中 462 通过、0 失败、1 项按原规则跳过(可选 TavernDesk 固定夹具不存在);包含类型检查、锁定还原、Gateway/三个原生组件发布、生产 stdio 和交付校验。报告 `test-tmp/check/2026-09-12T08-51-11-904Z-core/report.json`。buildId=`7491337da2426a3ccc4f2df4a11a5f28982fc86852ae5485305ffeb78bceec7e`,17 工具、Schema 不变;交付 contentId=`66567fe4521c917d27922e3181a5fd4dba99fec9adfe4c537cfafcb71a43fcd0`。未重复全量测试,未运行 Node 22 或真实桌面图片流程。 +- Skill 入口保持短路由,仅在既有 diagnostics 手册首节加入会话流程;同步 README、配置指南、架构说明、贡献指南和 changelog。`quick_validate.py` 以 Python UTF-8 模式通过;默认 Windows GBK 解码曾失败,未改校验器。四份安装文件经既有同步脚本备份/同步/哈希核验一致,备份为 `C:/Users/40218/.agents/skills/.wincode-backup-95536b4a-f736-4d58-8588-e6ea88547904`。 +- 新入口和完整回归通过后,仅将 `C:/Users/40218/.codex/config.toml` 的 `[mcp_servers.wincode] enabled` 从 true 改为 false,其他字节保留;原配置备份 `config.toml.before-wincode-on-demand-20260912-165236.bak`。`codex mcp get wincode --json` 已读回 enabled=false。修改前建立的 4 个 Gateway 仍在,未终止其他任务连接或重启当前 Codex;正常刷新后“未首次调用无新 WinCode 进程”的宿主冷启动观察保留到现有待验表中。 + +## 2026-09-12 — 使用本地 Tavern 验证 Skill 按需会话 + +- 用户要求改用本地 Tavern 项目验证。目标为 `I:/New-tarven`,HEAD=`6eee26031ffe7d01cd676edd5fafc0af1e14dd3d`;开始和结束时 Git 状态均仅有原有 `.publish-verify/` 未跟踪目录。未修改 Tavern 源码、依赖或正常启动配置,也未提交/推送本轮 WinCode 改动。 +- 按当前隔离入口做 Release `--no-restore` 构建。首次 `-Fresh` 已在新建 `work/isolated-test-20260912-165839-478bbf71ef4643dbb290a7784643ae44` 初始化 schema 24 数据库,但停在语言选择对话框,脚本按 30 秒预算终止自有进程。核对代码/回执后,仅对本轮新建且具有专用标记的目录使用既有 `--test-reuse` 参数,选择默认语言并启动窗口。数据库、配置、日志和 API 测试输出路径均由实际启动回执确认在测试根内;未调用 Provider 或使用个人资料库。 +- 通过当前 Codex 的 `exec_command(tty:true)` 和同一 `write_stdin` 会话执行 9 次显式工具请求。首次请求前仅有入口 PID=33064,没有 Gateway 子进程;随后固定到 `I:/New-tarven`,显式配置 App 项目的 Release/net10.0-windows Roslyn。Gateway PID=34856,instanceId=`d014e125-472c-47a7-9207-705c986b383c`,buildId=`7491337da2426a3ccc4f2df4a11a5f28982fc86852ae5485305ffeb78bceec7e`。 +- 文本搜索返回 `ShowCharactersAsync` 的三处匹配;Roslyn 声明位于 MainWindowViewModel.cs 第 315 行,精确引用位于 127、130 行,与实际源码一致。两次上下文响应的所有正文行均与磁盘独立读取一致。UI 取证前后,引用仍使用同一 snapshotId=`2d27e127260b413694440f9b71e7a77b`,重复引用结果相同,Gateway 实例和构建身份保持一致。 +- 在隔离窗口 PID=29192、HWND=0x120B9C 上后台查询 NavCharacters,结果 unique、searchComplete=true,返回启用的“角色”按钮及三节点子树。实际截图 2160×1350、81461 字节,已查看,内容为新资料库的空角色书架;附件与 MCP 原始 image 数据逐字节一致,SHA-256=`e094f01e3b5be5cff85462b1e7f809fcc6732f1f3efdc6e32286253fdb3ffc3f`。XAML 第 116 行与 C# 第 127/197 行候选成功返回,按 nextRequest 续读赋值行;候选文件哈希已核对,runtimeSourceVerified=false 仍保留。 +- 显式关闭 Skill 会话后,按 PID/创建时间记录的 5 个所属进程均退出,survivors=[];Tavern 目标窗口仍存活,随后单独正常关闭该测试窗口并观察到进程退出。第一次收尾身份比较因 PowerShell 将 JSON ISO 时间自动解析为 DateTime 而拒绝执行;实际时间和命令行确认一致后按 UTC ticks 比较,未放宽进程身份检查。 +- 实际项目揭示语义边界:快照排除 24 个分析器/生成器,返回两项 CS8795 及部分 Microsoft.Data/Microsoft.ML 的 CS0234,queryComplete=false、analysisCompleteness=incomplete。上述具体导航和引用查询通过,不代表全项目语义完整;没有通过安装依赖、改项目 TFM 或忽略诊断掩盖缺口。未重复完整回归或新增自动测试;本轮补齐真实按需入口的图片附件验证,但仍未重启 Codex 或运行 Node 22。 +- 完整本地报告:`test-tmp/tavern-skill-JNZ7Wu/report.json`,包含隔离启动、未调用进程状态、查询/快照、图片校验及退出证据;原始 MCP 响应与截图在 `test-tmp/skill-sessions/run-NCIlEv/`。这些源码/图片附件只保存在忽略的本地目录,现有待验表已同步本轮结果和覆盖限制。 + +## 2026-09-12 — Node 22 本地兼容性验证 + +- 用户要求 Node 22 验证,并在确认本机只有 Node 24 后批准下载便携运行时。官方 Node 22.23.2 Windows x64 ZIP 保存到 `.deps/node-v22.23.2-win-x64.zip`(35683585 字节),SHA-256=`1177b4137ba5adaa56354ae40f1080c7450e8ae09cecb47da459d1c52ac99f97`,与官方该版本 SHASUMS256.txt 一致;校验归档路径后解压到 `.deps/node-v22.23.2-win-x64/`。没有全局安装、修改系统 PATH 或切换默认 Node,运行时和归档均由既有 `.deps/` 规则排除 Git。 +- 使用便携版绝对路径先运行既有 `tests/skill-session.test.ts`,3/3 通过、0 跳过,包括未调用状态、真实 Roslyn 快照复用/失效,以及取消后所属进程清理。日志 `test-tmp/node22-validation/skill-session.log`。没有新增测试或修改生产代码。 +- 随后仅在检查子进程环境中前置便携版 PATH,并以同一 Node 执行 `scripts/check.mjs`;父进程和通过 PATH 启动的子 Node 均读回 v22.23.2。完整核心检查通过:463 项中 462 通过、0 失败、1 项跳过(原有可选 Tavern 固定路径不存在),类型检查、Gateway/原生构建、生产 stdio 和交付核验全部成功。报告 `test-tmp/check/2026-09-12T09-20-46-120Z-core/report.json`。Gateway buildId 与 Node 24 构建一致,仍为 `7491337da2426a3ccc4f2df4a11a5f28982fc86852ae5485305ffeb78bceec7e`;记录 Node 22 工具链的新交付 contentId=`7592124791ab0b901a432405cbcdb4b87f2c7312bfe093c4ebca2a553576e1a0`,matched=true。 +- 在当前 Codex 交互终端使用便携版启动真实 Skill 入口,绑定 `I:/New-tarven` 和上一轮显式授权的 Roslyn 配置。4 次显式请求覆盖 hello、声明搜索、精确引用和再次 hello;Gateway 两次自报 v22.23.2,实际入口 PID=26740、Gateway PID=9412 的命令行均指向便携版 Node 22。instanceId=`87e949f7-776c-4380-86ed-3623bcc09f42` 保持一致,snapshotId=`6c1db72b22ea42ec85a3a87959ffdda1` 从声明传到引用;第 127、130 行两处引用按实际源码 UTF-16 位置核验成功。Tavern 已知设计时语义缺口仍返回 incomplete,没有把版本兼容性通过解释为全项目语义完整。 +- 会话显式关闭并退出;记录的 5 个所属进程全部消失,survivors=[]。新执行环境读回系统默认 Node 仍为 v24.19.0。汇总报告 `test-tmp/node22-validation/report.json`,原始 MCP 响应位于 `test-tmp/skill-sessions/run-lRbqki/`。本轮未重启 Codex、未复跑桌面或 CI 的全部附加专项,未提交/推送;现有待验表移除 Node 22 未验状态,保留宿主冷启动观察。 diff --git a/package.json b/package.json index 4efaf99..b261b61 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "node scripts/build.mjs", "start": "node dist/index.js", "dev": "tsx src/index.ts --development", - "test": "tsx --test tests/core-cache.test.ts tests/workspace-files.test.ts tests/text-symbols.test.ts tests/context-packing.test.ts tests/composite-tools.test.ts tests/mcp-stdio.test.ts tests/stability-lifecycle.test.ts tests/cache-budgets.test.ts tests/process-failures.test.ts tests/request-concurrency.test.ts tests/evidence-confidence.test.ts tests/watch-invalidation.test.ts tests/resource-cleanup.test.ts tests/stage1-cleanup.test.ts tests/ui-hardening.test.ts tests/ui-source-review.test.ts tests/v071-acceptance.test.ts tests/ui-background.test.ts tests/ui-audit.test.ts tests/ui-query.test.ts tests/context-efficiency.test.ts tests/agent-efficiency-benchmark.test.ts tests/workspace-summary.test.ts tests/runtime-identity.test.ts tests/runtime-contract.test.ts tests/context-coverage.test.ts tests/semantic-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/local-text.test.ts tests/repomix-disabled.test.ts tests/workspace-watch-close.test.ts tests/workspace-lifecycle.test.ts tests/failure-recovery.test.ts tests/tool-contracts.test.ts tests/architecture-boundaries.test.ts tests/lifecycle-cancellation.test.ts tests/delivery-contract.test.ts tests/roslyn-contracts.test.ts tests/gateway-exit.test.ts tests/owner-process-guard.test.ts tests/manual-release.test.ts tests/tray-client.test.ts tests/runtime-cache-regressions.test.ts tests/architecture-safety.test.ts tests/resource-identity.test.ts tests/check-reporting.test.ts tests/fixed-workspace.test.ts tests/request-admission.test.ts tests/design-time-artifacts.test.ts", + "test": "tsx --test tests/core-cache.test.ts tests/workspace-files.test.ts tests/text-symbols.test.ts tests/context-packing.test.ts tests/composite-tools.test.ts tests/mcp-stdio.test.ts tests/stability-lifecycle.test.ts tests/cache-budgets.test.ts tests/process-failures.test.ts tests/request-concurrency.test.ts tests/evidence-confidence.test.ts tests/watch-invalidation.test.ts tests/resource-cleanup.test.ts tests/stage1-cleanup.test.ts tests/ui-hardening.test.ts tests/ui-source-review.test.ts tests/v071-acceptance.test.ts tests/ui-background.test.ts tests/ui-audit.test.ts tests/ui-query.test.ts tests/context-efficiency.test.ts tests/agent-efficiency-benchmark.test.ts tests/workspace-summary.test.ts tests/runtime-identity.test.ts tests/runtime-contract.test.ts tests/context-coverage.test.ts tests/semantic-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/local-text.test.ts tests/repomix-disabled.test.ts tests/workspace-watch-close.test.ts tests/workspace-lifecycle.test.ts tests/failure-recovery.test.ts tests/tool-contracts.test.ts tests/architecture-boundaries.test.ts tests/lifecycle-cancellation.test.ts tests/delivery-contract.test.ts tests/roslyn-contracts.test.ts tests/gateway-exit.test.ts tests/owner-process-guard.test.ts tests/manual-release.test.ts tests/tray-client.test.ts tests/runtime-cache-regressions.test.ts tests/architecture-safety.test.ts tests/resource-identity.test.ts tests/check-reporting.test.ts tests/fixed-workspace.test.ts tests/request-admission.test.ts tests/design-time-artifacts.test.ts tests/skill-session.test.ts", "test:verify": "tsx tests/verify.ts", "benchmark:agent": "tsx scripts/benchmark-agent-efficiency.ts", "test:benchmark": "tsx --test tests/agent-efficiency-benchmark.test.ts", diff --git a/skills/wincode/SKILL.md b/skills/wincode/SKILL.md index 8549eca..c046b3e 100644 --- a/skills/wincode/SKILL.md +++ b/skills/wincode/SKILL.md @@ -5,16 +5,18 @@ description: 使用 WinCode MCP 读取 Windows/.NET 项目源码、引用和变 # WinCode -适用于 WinCode 0.15.0。使用已连接的 MCP 工具,名称和参数以该连接实际暴露的 Schema 为准。 +适用于 WinCode 0.15.0。已有正确工作区的 MCP 连接时直接使用;采用按需模式时,首次需要 WinCode 才按[诊断手册的会话入口](references/diagnostics.md#skill-按需会话)启动。Skill 被发现或读取不需要预启动任何进程。 只读取与当前任务有关的手册: - [代码与工作区](references/code.md):源码搜索、上下文、引用、影响分析和 Roslyn 配置。 - [窗口与 UI](references/ui.md):窗口选择、截图、控件读取和源码候选。 -- [诊断与恢复](references/diagnostics.md):工具不可用、版本、运行状态及错误恢复。 +- [诊断与恢复](references/diagnostics.md):按需会话的启动、复用、结果读取和关闭,以及版本和故障恢复。按需模式先只读该手册首节。 连接固定到启动工作区;已知根一致时直接查询,不例行重复打开。`WORKSPACE_MISMATCH` 时选择目标项目的连接,可参考 `connectionGuide`;`workspace_open` 只能确认或恢复原工作区。 +按需模式在一次任务内保留执行会话 ID,多次查询复用同一连接;读取回执中的完整结果文件,保留所有 MCP 内容块与 `isError`。任务结束或放弃时显式关闭;断线后不自动重放,旧符号定位不能跨新连接使用。工具名和参数以运行实例的 Schema 为准,疑问时使用 `wincode_hello_world({toolName:"具体工具名"})`。 + 默认 `local-text` 提供文本线索;显式启用 Roslyn 才有 C# 语义证据。需要精确引用时先搜索声明,再传回完整 `location`,不猜定位或复用过期快照。 日常导航用 `wincode_search_text` 限定目录查字面量、`wincode_file_outline` 查看行数和声明,再把返回的 `nextRequest` 交给 `wincode_prepare_context`。先看 `summary` 的范围和缺口,再核对正文与覆盖率。UI 首轮可显式用 `responseFormat:"compact"`,需要几何或更多信息时按 `expansionRequests` 展开;仅使用本连接已声明的能力。 diff --git a/skills/wincode/references/diagnostics.md b/skills/wincode/references/diagnostics.md index 4eaa4cb..4eede55 100644 --- a/skills/wincode/references/diagnostics.md +++ b/skills/wincode/references/diagnostics.md @@ -1,5 +1,43 @@ # 诊断与审计 +## Skill 按需会话 + +适用于能保留交互终端会话的 Codex。首次确实需要 WinCode 才启动入口;不要因加载 Skill、开始聊天或查看配置而预热。目标工作区取当前任务的绝对目录,安装目录取已核实的本机交付位置(已有 MCP 配置中的 `dist/index.js` 路径也可用于定位);不照抄其他机器或历史测试项目的路径。 + +按需模式要求客户端不再自动连接同一个 WinCode:安装切换时将其原生 MCP 配置禁用,并让客户端刷新连接。保留原配置以便恢复;日常调用不自行修改客户端配置。有可用且工作区正确的原生连接时直接使用,不同时再启动按需入口。没有持久执行工具时使用已配置的原生 MCP;不要每次调用都临时启动、关闭服务器。 + +**启动一次。** 通过 `exec_command` 执行以下命令,必须设置 `tty:true`(普通管道会立即 EOF),保留返回的 `session_id`。PowerShell 路径用正确引号转义,不把不可信文本拼成命令: + +```powershell +node "/dist/Client/SkillSessionCli.js" --workspace "<目标工作区绝对路径>" +``` + +仅目标项目已有明确授权的 Roslyn 配置时追加 `--roslyn-config "<配置文件绝对路径>"`;不沿用其他项目的配置,也不从工作区自动发现并执行配置。默认 local-text 不执行 MSBuild。`ready` 回执包含入口 `ownerPid`;此时 `status.state="unused"`、Gateway `pid=null`。首次工具请求才创建 Gateway,并校验工作区、构建和提供方;随后复用同一 Gateway、Host 和有效快照。 + +**连续调用。** 使用 `write_stdin` 向同一个 `session_id` 发送单行 JSON;Windows 交互终端的 `chars` 以 `\r` 结束。请求 `id` 使用本会话内不同的 1–64 位字母、数字、点、下划线或连字符。示例请求体: + +```json +{"id":"q1","tool":"wincode_search_text","arguments":{"query":"Save","scopePaths":["src"]},"timeoutMs":120000} +``` + +一次只发一个工具请求,等待该 `id` 的回执再继续;若执行工具先返回正在运行,继续轮询同一个执行会话,不重新发送请求。`timeoutMs` 默认 120000,范围 1–180000,包含本次调用的连接等待;单独握手有 30 秒限制。请求行最多 64 KiB。终端回显或折行不是第二个响应,不能当作完整 MCP JSON。 + +**读取结果。** 回执提供 `resultFile`、`isError` 和 `imageFiles`。用文件/执行工具读取 `resultFile` 中的完整 MCP `CallToolResult`,包括 text、structuredContent、image 等内容块;有图片时按需使用本地图片查看工具读取 `imageFiles[].path`。不要把 `isError:true` 当成成功,也不要只读短回执就推断业务结果。原始 JSON 和图片保存到安装目录的 `test-tmp/skill-sessions/run-*/`,关闭不会自动删除,按本地附件管理;不要提交这些文件。文本结果至少需要一次额外文件读取,这是按需模式的调用成本。 + +**观察、取消、关闭。** 同样发送单行 JSON: + +```json +{"id":"s1","action":"status"} +{"id":"c1","action":"cancel","targetId":"q1"} +{"id":"end","action":"close"} +``` + +`status` 只读本地连接状态,不启动 Gateway。取消必须对应当前活动请求;`cancellationRequested` 只证明信号已发送,不能解释为 Host 已退出。任务结束、放弃或准备换工作区时发送 `close`,等 `closed:true` 和入口退出;一轮内仍需后续查询时保留连接。调用异常时也在收尾中关闭。EOF/入口死亡会关闭 Gateway 输入,原有 Gateway/Host 所属进程清理继续生效;当前终端工具持续保留入口时不会自动到期释放。 + +`requestError` 是入口拒绝,`transportError` 是连接/协议/取消失败,不能据此认定业务没有执行;`resultDeliveryError` 且 `toolResponded:true` 表示已收到工具结果、但附件交付失败。出现这些错误先核对实际状态,尤其文件移动,不自动重放。关闭或失联后要显式建新会话并重新搜索声明,不能复用旧 `location`。不要使用 Node REPL 绕过模块限制:当前 SDK 需要 `node:process`,本机 REPL 不允许该导入;支持的入口是上述交互终端。 + +## 原生 MCP 连接与故障诊断 + 跨项目入口:新构建的 `WORKSPACE_MISMATCH` 响应包含 `connectionGuide`,其中 `configuration.command/args` 是独立 STDIO 连接配置,`verification` 给出连接后检查工作区的调用。也可运行 `node /dist/index.js --print-connection --workspace <目标绝对路径>` 输出同一配置;不创建缓存、不注册或重启客户端、不启动项目 Host。配置默认 local-text,不复制已有 Roslyn、开发或托盘选项;目录存在性在实际连接启动时校验。选择已有正确连接优先,建立新连接仍遵守用户授权。刷新连接后再使用新增导航工具或 UI 精简参数,磁盘重建和 Skill 同步不会热替换旧 MCP Schema。 0.15.0 的 WORKSPACE_MISMATCH 是固定工作区拒绝:检查 activeWorkspace/requestedWorkspace,选择对应项目连接。错误发生在工作区资源变更之前,不表示旧根已切换或需要清空缓存。hello.health.workspaceBinding 给出固定根及启动来源;argument 是显式 CLI 参数,cwd 是启动目录回退,configuration 是嵌入式配置。显式 --workspace 必须有绝对目录值;已有连接不会因磁盘重建或配置保存自行更新。 @@ -30,7 +68,7 @@ hello.health.cache 仅读取内存及最近显式磁盘观察:diskObservation= 仅遇到故障或用户要求时调用 wincode_hello_world({}) 查看适配器、工作区及 runtime;环境问题再用 wincode_diagnose_project({})。本地文本健康成功不证明 Roslyn 已配置或项目已加载;watcher 停止、最近超时和清理错误如实报告,不自动安装依赖或循环重启。 -工具不可用:先确认客户端是否启用了 wincode MCP;已保存配置通常需重新加载客户端/会话。Skill 不负责注册 MCP。安装路径取实际客户端配置,不沿用历史机器的 I:/WinCode。STDIO 配置结构(占位路径需替换): +原生模式工具不可用:先确认客户端是否启用了 wincode MCP;已保存配置通常需重新加载客户端/会话。按需模式按本手册首节使用执行会话,不要求注册 MCP。安装路径取实际客户端配置,不沿用历史机器的 I:/WinCode。STDIO 配置结构(占位路径需替换): - 命令:node - 独立参数:/dist/index.js、--workspace、<目标工作区绝对路径> diff --git a/src/Client/SkillSession.ts b/src/Client/SkillSession.ts new file mode 100644 index 0000000..fc16f08 --- /dev/null +++ b/src/Client/SkillSession.ts @@ -0,0 +1,154 @@ +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { withTimeout } from '../Core/ResourceManager.js'; + +export interface SkillSessionOptions { + workspace: string; + /** An explicitly approved existing Roslyn launch configuration; never discovered from the workspace. */ + roslynConfig?: string; +} + +/** SDK close() may return after signalling the child. Observe the actual transport close as well. */ +class ObservedTransport extends StdioClientTransport { + private exitedResolve!: () => void; + readonly exited = new Promise(resolve => { this.exitedResolve = resolve; }); + override async start(): Promise { + const previous = this.onclose; + this.onclose = () => { this.exitedResolve(); previous?.(); }; + await super.start(); + } +} + +function absolute(value: string, name: string): string { + if (typeof value !== 'string' || !path.isAbsolute(value)) throw new Error(`${name} must be an absolute path.`); + return path.resolve(value); +} + +function samePath(a: string, b: string): boolean { + return process.platform === 'win32' ? path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase() + : path.resolve(a) === path.resolve(b); +} + +/** A caller can cancel its wait without cancelling another caller's shared connection attempt. */ +async function waitFor(pending: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + let cancel!: () => void; + const aborted = new Promise((_, reject) => { + cancel = () => reject(signal.reason); + signal.addEventListener('abort', cancel, { once: true }); + }); + try { return await Promise.race([pending, aborted]); } + finally { signal.removeEventListener('abort', cancel); } +} + +/** + * Importing/constructing this object creates no process, timer, file or connection. + * Keep one object in the caller's persistent execution context. It never reconnects or replays a call. + */ +export class WinCodeSession { + readonly workspace: string; + private readonly roslynConfig?: string; + private readonly shutdown = new AbortController(); + private client?: Client; + private transport?: ObservedTransport; + private connection?: Promise; + private closing?: Promise; + private disposal?: Promise; + private state: 'unused' | 'connecting' | 'connected' | 'failed' | 'closing' | 'closed' = 'unused'; + private stderr = ''; + private failure?: Error; + private identity: { instanceId: string; buildId: string; schemaHash: string } | null = null; + + constructor(options: SkillSessionOptions) { + this.workspace = absolute(options.workspace, 'workspace'); + if (options.roslynConfig !== undefined) this.roslynConfig = absolute(options.roslynConfig, 'roslynConfig'); + } + + /** Local observation only; unlike call('wincode_hello_world'), this never opens the session. */ + get status() { + return { state: this.state, workspace: this.workspace, pid: this.transport?.pid ?? null, + identity: this.identity ? { ...this.identity } : null, error: this.failure?.message ?? null }; + } + + async call(name: string, args: Record = {}, options: { signal?: AbortSignal; timeoutMs?: number } = {}) { + if (typeof name !== 'string' || !name.trim()) throw new Error('Supply a tool name.'); + if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Tool arguments must be an object.'); + const timeout = options.timeoutMs ?? 120_000; + if (!Number.isInteger(timeout) || timeout < 1 || timeout > 180_000) throw new Error('timeoutMs must be 1–180000.'); + if (['failed', 'closing', 'closed'].includes(this.state)) throw this.failure ?? new Error('Session is closed; create a new session explicitly.'); + const deadline = Date.now() + timeout; + const signal = AbortSignal.any([this.shutdown.signal, AbortSignal.timeout(timeout), ...(options.signal ? [options.signal] : [])]); + signal.throwIfAborted(); + this.connection ??= this.connect(); + await waitFor(this.connection, signal); + signal.throwIfAborted(); + // Return every MCP content block and isError unchanged. Tool failures are not transport failures. + return this.client!.callTool({ name, arguments: args }, { signal, timeout: Math.max(1, deadline - Date.now()) }); + } + + private async connect(): Promise { + this.state = 'connecting'; + try { + const root = fileURLToPath(new URL('../../', import.meta.url)); + const manifest = JSON.parse(await fs.readFile(path.join(root, 'dist/build-manifest.json'), 'utf8')); + if (!/^[a-f0-9]{64}$/.test(manifest.buildId)) throw new Error('Build WinCode before opening a Skill session.'); + this.shutdown.signal.throwIfAborted(); + const args = [path.join(root, 'dist/index.js'), '--workspace', this.workspace]; + if (this.roslynConfig) args.push('--roslyn-config', this.roslynConfig); + this.transport = new ObservedTransport({ command: process.execPath, args, cwd: root, stderr: 'pipe' }); + this.transport.stderr!.on('data', chunk => { this.stderr = (this.stderr + chunk.toString()).slice(-8192); }); + this.client = new Client({ name: 'wincode-skill-session', version: '1' }); + this.client.onclose = () => { + if (!this.closing && this.state !== 'failed') { + this.state = 'failed'; + this.failure = new Error('WinCode connection closed; create a new session and search again.'); + } + }; + await this.client.connect(this.transport, { signal: this.shutdown.signal, timeout: 30_000 }); + const result = await this.client.callTool({ name: 'wincode_hello_world', arguments: {} }, + { signal: this.shutdown.signal, timeout: 30_000 }); + const text = result.content.find(item => item.type === 'text'); + const hello = text?.type === 'text' ? JSON.parse(text.text) : null; + if (result.isError || !hello || typeof hello.workspace !== 'string' || !samePath(hello.workspace, this.workspace) + || hello.runtime?.build?.status !== 'verified' || hello.runtime.build.buildId !== manifest.buildId + || hello.codeProvider !== (this.roslynConfig ? 'roslyn' : 'local-text')) + throw new Error('WinCode connection identity does not match the requested workspace, build or provider.'); + this.shutdown.signal.throwIfAborted(); + this.identity = { instanceId: hello.runtime.instanceId, buildId: hello.runtime.build.buildId, schemaHash: hello.toolContract.schemaHash }; + this.state = 'connected'; + } catch (error) { + this.failure = new Error(`${error instanceof Error ? error.message : String(error)}${this.stderr ? `\n${this.stderr}` : ''}`); + this.state = 'failed'; + try { await this.disposeTransport(); } + catch (cleanup) { this.failure = new AggregateError([this.failure, cleanup], 'Skill connection and cleanup failed.'); } + throw this.failure; + } + } + + close(): Promise { + this.closing ??= this.closeOnce(); + return this.closing; + } + + private async closeOnce(): Promise { + this.state = 'closing'; + this.shutdown.abort(new Error('Skill session closed.')); + await this.connection?.catch(() => {}); + await this.disposeTransport(); + this.state = 'closed'; + } + + private disposeTransport(): Promise { + this.disposal ??= (async () => { + if (!this.transport) return; + const started = this.transport.pid !== null; + try { await this.client?.close(); } + finally { await this.transport.close(); } + if (started) await withTimeout(this.transport.exited, 8000, 'Skill Gateway exit'); + })(); + return this.disposal; + } +} diff --git a/src/Client/SkillSessionCli.ts b/src/Client/SkillSessionCli.ts new file mode 100644 index 0000000..82e8ca3 --- /dev/null +++ b/src/Client/SkillSessionCli.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { WinCodeSession } from './SkillSession.js'; + +const argv = process.argv.slice(2); +let workspace: string | undefined, roslynConfig: string | undefined; +for (let i = 0; i < argv.length; i += 2) { + if (!argv[i + 1]) throw new Error('Expected --workspace [--roslyn-config ].'); + if (argv[i] === '--workspace' && !workspace) workspace = argv[i + 1]; + else if (argv[i] === '--roslyn-config' && !roslynConfig) roslynConfig = argv[i + 1]; + else throw new Error(`Unknown or duplicate launch option: ${argv[i]}`); +} +if (!workspace) throw new Error('--workspace is required.'); +const session = new WinCodeSession({ workspace, roslynConfig }); +let buffer = '', stopping = false, serial = 0; +let active: { id: string; controller: AbortController; done: Promise } | undefined; +let outputDirectory: string | undefined; +const write = (value: unknown) => process.stdout.write(JSON.stringify(value) + '\n'); +const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(-8192); + +async function respond(id: string, result: Awaited>) { + const serialized = JSON.stringify(result); + const images = result.content.filter(item => item.type === 'image'); + // PTYs can wrap/echo even small JSON. Always retain the exact result outside terminal rendering. + if (!outputDirectory) { + const parent = fileURLToPath(new URL('../../test-tmp/skill-sessions/', import.meta.url)); + await fs.mkdir(parent, { recursive: true }); + outputDirectory = await fs.mkdtemp(path.join(parent, 'run-')); + } + const stem = String(++serial), resultFile = path.join(outputDirectory, `${stem}.json`); + await fs.writeFile(resultFile, serialized + '\n', { flag: 'wx' }); + const imageFiles = []; + for (const [index, item] of images.entries()) { + if (item.type !== 'image') continue; + const extension = item.mimeType === 'image/png' ? 'png' : item.mimeType === 'image/jpeg' ? 'jpg' : 'image'; + const file = path.join(outputDirectory, `${stem}-${index}.${extension}`); + await fs.writeFile(file, Buffer.from(item.data, 'base64'), { flag: 'wx' }); + imageFiles.push({ path: file, mimeType: item.mimeType }); + } + write({ id, isError: result.isError ?? false, resultFile, imageFiles }); +} + +async function stop(id: string | null) { + if (stopping) return; + stopping = true; + active?.controller.abort(new Error('Skill session stopping.')); + let failure: string | undefined; + try { await session.close(); await active?.done; } + catch (error) { failure = errorText(error); } + process.stdin.pause(); + process.stdout.write(JSON.stringify({ id, closed: !failure, status: session.status, ...(failure ? { error: failure } : {}) }) + '\n', + () => process.exit(failure ? 1 : 0)); +} + +function receive(line: string) { + let id: string | null = null; + try { + if (Buffer.byteLength(line, 'utf8') > 65_536) throw new Error('Session request exceeds 64 KiB.'); + const request = JSON.parse(line); + if (!request || typeof request.id !== 'string' || !/^[A-Za-z0-9_.-]{1,64}$/.test(request.id)) + throw new Error('Each request needs an id of 1–64 letters, digits, dots, underscores or hyphens.'); + id = request.id; + if (stopping) throw new Error('Session is stopping.'); + if (request.action === 'status') { write({ id, status: session.status }); return; } + if (request.action === 'close') { void stop(id); return; } + if (request.action === 'cancel') { + if (!active || request.targetId !== active.id) throw new Error('targetId must identify the active request.'); + active.controller.abort(new Error('Skill request cancelled.')); + write({ id, cancellationRequested: true, targetId: active.id }); return; + } + if (request.action !== undefined) throw new Error('Unknown session action.'); + if (active) throw new Error('A request is already active; await its result or cancel it explicitly.'); + const controller = new AbortController(); + const current = { id: id!, controller, done: Promise.resolve() }; + active = current; + current.done = (async () => { + try { + const result = await session.call(request.tool, request.arguments ?? {}, { signal: controller.signal, timeoutMs: request.timeoutMs }); + try { await respond(current.id, result); } + catch (error) { write({ id: current.id, resultDeliveryError: errorText(error), toolResponded: true, isError: result.isError ?? false }); } + } + catch (error) { write({ id: current.id, transportError: errorText(error) }); } + finally { if (active === current) active = undefined; } + })(); + } catch (error) { write({ id, requestError: errorText(error) }); } +} + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); + if (line) receive(line); + } + if (Buffer.byteLength(buffer, 'utf8') > 65_536) { write({ requestError: 'Session input exceeds 64 KiB without a newline.' }); void stop(null); } +}); +process.stdin.once('end', () => { void stop(null); }); +process.stdin.once('error', () => { void stop(null); }); +process.stdout.once('error', () => { void stop(null); }); +process.on('SIGINT', () => { void stop(null); }); +process.on('SIGTERM', () => { void stop(null); }); +write({ ready: true, protocol: 'wincode-skill-session/1', ownerPid: process.pid, status: session.status }); diff --git a/tests/skill-session.test.ts b/tests/skill-session.test.ts new file mode 100644 index 0000000..2271e00 --- /dev/null +++ b/tests/skill-session.test.ts @@ -0,0 +1,165 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { WinCodeSession } from '../src/Client/SkillSession.js'; +import { withTimeout } from '../src/Core/ResourceManager.js'; + +const repo = path.resolve(import.meta.dirname, '..'), temporary = path.join(repo, 'test-tmp'); +const cli = path.join(repo, 'dist/Client/SkillSessionCli.js'); +const { ownedProcesses, observedSurvivors, terminateObserved } = await import(pathToFileURL(path.join(repo, 'scripts/lib/owned-processes.mjs')).href); +const { resolveDotnet, runDotnet } = await import(pathToFileURL(path.join(repo, 'scripts/lib/dotnet.mjs')).href); +const payload = (result: any) => JSON.parse(result.content.find((item: any) => item.type === 'text').text); + +async function fixture(roslyn = false) { + await fs.mkdir(temporary, { recursive: true }); + const root = await fs.mkdtemp(path.join(temporary, 'skill-session-test-')); + await fs.writeFile(path.join(root, 'Probe.ts'), 'export function coldProbe() { return 7; }\n'); + let config: string | undefined; + if (roslyn) { + const sdk = resolveDotnet(repo); + await fs.writeFile(path.join(root, 'Probe.csproj'), 'net10.0false'); + await fs.writeFile(path.join(root, 'Probe.cs'), 'public static class Api { public static void Save() {} }\nclass Use { void Run() { Api.Save(); } }\n'); + await fs.copyFile(path.join(repo, 'global.json'), path.join(root, 'global.json')); + const emptySource = path.join(root, 'empty-source'); await fs.mkdir(emptySource); + runDotnet(sdk, ['restore', path.join(root, 'Probe.csproj'), '--source', emptySource, '--nologo'], root, 30000); + config = path.join(root, 'roslyn.json'); + await fs.writeFile(config, JSON.stringify({ enabled: true, allowProjectEvaluation: true, project: 'Probe.csproj', + configuration: 'Debug', targetFramework: 'net10.0', dotnetPath: sdk.dotnet, + hostPath: path.join(repo, 'tools/WinCode.Code.Host/bin/Release/net10.0/publish/WinCode.Code.Host.dll') })); + } + return { root, config, remove: async () => { + assert.equal(path.dirname(path.resolve(root)), temporary); + await fs.rm(root, { recursive: true, force: true }); + } }; +} + +function startCli(root: string, config?: string) { + const child = spawn(process.execPath, [cli, '--workspace', root, ...(config ? ['--roslyn-config', config] : [])], + { cwd: repo, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + const messages: any[] = []; let output = '', error = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', chunk => { + output += chunk; + let end; + while ((end = output.indexOf('\n')) >= 0) { messages.push(JSON.parse(output.slice(0, end))); output = output.slice(end + 1); } + }); + child.stderr.on('data', chunk => { error = (error + chunk.toString()).slice(-8192); }); + const exited = new Promise(resolve => child.once('close', code => resolve(code))); + const wait = async (match: (value: any) => boolean) => { + const deadline = Date.now() + 30000; + while (!messages.some(match)) { + assert.ok(Date.now() < deadline && child.exitCode === null, `Missing response: ${error}\n${JSON.stringify(messages)}`); + await new Promise(resolve => setTimeout(resolve, 20)); + } + return messages.find(match); + }; + return { child, exited, wait, send: (value: unknown) => child.stdin.write(JSON.stringify(value) + '\n') }; +} + +async function stopChild(child: ChildProcessWithoutNullStreams, exited: Promise) { + child.stdin.end(); + try { await withTimeout(exited, 10000, 'Skill CLI exit'); } + catch (error) { if (child.exitCode === null && child.signalCode === null) child.kill(); throw error; } +} + +it('Skill entry stays cold until a call, reuses its Gateway and preserves MCP errors and results', { timeout: 45000 }, async () => { + const f = await fixture(), driver = startCli(f.root); + let processes: any[] = []; + try { + const ready = await driver.wait(value => value.ready); + assert.equal(ready.status.state, 'unused'); assert.equal(ready.status.pid, null); + const unused = ownedProcesses(driver.child.pid); + // Windows may attach a console host even with redirected pipes; it is not a WinCode runtime. + assert.deepEqual(unused.filter((p: any) => p.ProcessId !== driver.child.pid && p.Name.toLowerCase() !== 'conhost.exe'), [], + `merely opening the entry must not spawn a Gateway: ${JSON.stringify(unused)}`); + assert.equal((await fs.readdir(f.root)).some(file => file === '.cache'), false); + driver.send({ id: 'status', action: 'status' }); + assert.equal((await driver.wait(value => value.id === 'status')).status.pid, null); + driver.send({ id: 'find', tool: 'wincode_search_text', arguments: { query: 'coldProbe', scopePaths: ['Probe.ts'] } }); + const found = await driver.wait(value => value.id === 'find'); + assert.equal(payload(JSON.parse(await fs.readFile(found.resultFile, 'utf8'))).returnedItems, 1); + driver.send({ id: 'identity', action: 'status' }); + const identity = (await driver.wait(value => value.id === 'identity')).status; + driver.send({ id: 'mismatch', tool: 'workspace_open', arguments: { path: repo } }); + const rejected = await driver.wait(value => value.id === 'mismatch'); + assert.equal(rejected.isError, true); + assert.equal(payload(JSON.parse(await fs.readFile(rejected.resultFile, 'utf8'))).errorCode, 'WORKSPACE_MISMATCH'); + driver.send({ id: 'again', tool: 'wincode_file_outline', arguments: { file: 'Probe.ts' } }); + const again = await driver.wait(value => value.id === 'again'); + assert.equal(payload(JSON.parse(await fs.readFile(again.resultFile, 'utf8'))).symbols[0].name, 'coldProbe'); + driver.send({ id: 'after', action: 'status' }); + const after = (await driver.wait(value => value.id === 'after')).status; + assert.equal(after.pid, identity.pid); assert.deepEqual(after.identity, identity.identity); + processes = ownedProcesses(driver.child.pid); + driver.send({ id: 'end', action: 'close' }); + assert.equal((await driver.wait(value => value.id === 'end')).closed, true); + assert.equal(await withTimeout(driver.exited, 8000, 'Skill normal close'), 0); + assert.deepEqual(observedSurvivors(processes), []); + } finally { await stopChild(driver.child, driver.exited); await f.remove(); } +}); + +it('one Skill session preserves a real Roslyn snapshot across calls and rejects expired locations', { timeout: 60000 }, async () => { + const f = await fixture(true), session = new WinCodeSession({ workspace: f.root, roslynConfig: f.config }); + const preCancelled = AbortSignal.abort(new Error('cancel before use')); + let processes: any[] = []; + try { + await assert.rejects(session.call('wincode_find_code_symbol', { query: 'Save' }, { signal: preCancelled }), /cancel before use/); + assert.equal(session.status.pid, null); + const [symbols, hello] = await Promise.all([session.call('wincode_find_code_symbol', { query: 'Save' }), session.call('wincode_hello_world')]); + const selected = payload(symbols).symbols.find((item: any) => item.name === 'Save'); + assert.ok(selected?.location); + const before = session.status; + assert.equal(before.identity?.instanceId, payload(hello).runtime.instanceId); + const refs = payload(await session.call('wincode_find_references', { symbolName: 'Save', symbolLocation: selected.location })); + assert.equal(refs.references.length, 1); + assert.equal(session.status.pid, before.pid); + await fs.appendFile(path.join(f.root, 'Probe.cs'), 'class Extra { void Run() { Api.Save(); } }\n'); + const stale = await session.call('wincode_find_references', { symbolName: 'Save', symbolLocation: selected.location }); + assert.equal(stale.isError, true); assert.equal(payload(stale).errorCode, 'SNAPSHOT_STALE'); + const refreshed = payload(await session.call('wincode_find_code_symbol', { query: 'Save' })).symbols.find((item: any) => item.name === 'Save'); + assert.equal(payload(await session.call('wincode_find_references', { symbolName: 'Save', symbolLocation: refreshed.location })).references.length, 2); + assert.notEqual(refreshed.location.snapshotId, selected.location.snapshotId); + processes = ownedProcesses(session.status.pid); + await session.close(); await session.close(); + assert.deepEqual(observedSurvivors(processes), []); + await assert.rejects(session.call('wincode_find_references', { symbolName: 'Save', symbolLocation: refreshed.location }), /closed/); + } finally { await session.close(); await f.remove(); } +}); + +it('cancelling real MSBuild work and killing the Skill owner reclaim the observed process tree', { timeout: 90000 }, async () => { + const f = await fixture(true), driver = startCli(f.root, f.config); + let observed: any[] = []; + try { + await driver.wait(value => value.ready); + const blocker = path.join(f.root, '.cache', 'blocker.mjs'), marker = path.join(f.root, '.cache', 'blocked'); + await fs.mkdir(path.dirname(blocker), { recursive: true }); + await fs.writeFile(blocker, "import fs from 'node:fs'; fs.writeFileSync(process.argv[2], String(process.pid)); setInterval(()=>{},1000);\n"); + const escape = (s: string) => s.replaceAll('&', '&').replaceAll('"', '"'); + const project = path.join(f.root, 'Probe.csproj'); + const original = await fs.readFile(project, 'utf8'); + const target = ``; + await fs.writeFile(project, original.replace('', target + '')); + driver.send({ id: 'blocked', tool: 'wincode_find_code_symbol', arguments: { query: 'Save' } }); + const deadline = Date.now() + 30000; + while (!(await fs.stat(marker).catch(() => null))) { assert.ok(Date.now() < deadline); await new Promise(resolve => setTimeout(resolve, 25)); } + observed = ownedProcesses(driver.child.pid); + assert.ok(observed.some((p: any) => p.CommandLine?.includes('BuildHost'))); + assert.ok(observed.some((p: any) => p.CommandLine?.includes(blocker))); + driver.send({ id: 'cancel', action: 'cancel', targetId: 'blocked' }); + assert.equal((await driver.wait(value => value.id === 'cancel')).cancellationRequested, true); + assert.match((await driver.wait(value => value.id === 'blocked')).transportError, /cancel/i); + // Parent death closes the Gateway's stdin; the Gateway/Native owner guards must finish actual cleanup. + driver.child.kill('SIGKILL'); + await withTimeout(driver.exited, 10000, 'Skill owner crash'); + const end = Date.now() + 12000; + let survivors = observedSurvivors(observed); + while (survivors.length && Date.now() < end) { await new Promise(resolve => setTimeout(resolve, 100)); survivors = observedSurvivors(observed); } + assert.deepEqual(survivors, []); + } finally { + for (const survivor of observedSurvivors(observed)) terminateObserved(survivor); + await stopChild(driver.child, driver.exited); await f.remove(); + } +});