ai agent
This commit is contained in:
@@ -196,6 +196,22 @@ reports truncation instead of silently dropping results.
|
||||
Discovery is per project (`~/.ra3modxml/endpoints/<project>.json`), so several
|
||||
VS Code windows can enable agent access at the same time without shadowing each
|
||||
other, and a client can never be silently answered from a different project.
|
||||
Each window also registers itself under `~/.ra3modxml/instances/`, and a small
|
||||
merged `~/.ra3modxml/index.json` lists the live instances and their project
|
||||
roots. A window that crashes is cleaned up by whichever instance starts next —
|
||||
no workspace has to be reopened first.
|
||||
|
||||
**No Node installation is required.** The launcher runs the bundled server on
|
||||
VS Code's own Electron binary (`ELECTRON_RUN_AS_NODE=1`) and only falls back to
|
||||
`node` from `PATH` if that binary is missing. The launcher is rewritten on every
|
||||
activation, so updating or moving VS Code does not break an existing MCP config.
|
||||
|
||||
The MCP server is plain JSON-RPC over stdio, so an agent can also query the
|
||||
index without any MCP setup at all by piping a request into the launcher. A
|
||||
matching CLI (`cli.js`, next to `mcpServer.js` in `dist/agent/`) answers from
|
||||
the live index when VS Code is running and from the exported snapshot
|
||||
otherwise; commands that need element context say so explicitly rather than
|
||||
returning an empty result.
|
||||
|
||||
See `docs/ai-agent-integration-plan.md` for the full design and progress.
|
||||
|
||||
|
||||
+5
-1
@@ -178,7 +178,11 @@ MCP Server 在扩展运行时优先查询内存中的实时索引;扩展关闭
|
||||
|
||||
`get_asset_references` 会沿 `inheritFrom` 祖先链遍历,并用 `definedIn` 标出条目实际写在哪个祖先的文件里,因此"这个单位自己没有 `WeaponSetUpdate`,但它继承的基础单位有"可以在一次调用里回答。它受 `depth`(默认 1,上限 3)、`targetTypes` 与 `maxEdges` 三重限制,并在截断时显式报告,而不是静默丢弃结果。
|
||||
|
||||
Endpoint 按项目存放(`~/.ra3modxml/endpoints/<project>.json`),因此多个 VS Code 窗口可以同时启用 AI Agent 访问而不互相覆盖,客户端也不会被静默地用另一个项目的数据回答。
|
||||
Endpoint 按项目存放(`~/.ra3modxml/endpoints/<project>.json`),因此多个 VS Code 窗口可以同时启用 AI Agent 访问而不互相覆盖,客户端也不会被静默地用另一个项目的数据回答。每个窗口还会在 `~/.ra3modxml/instances/` 下登记自己,并由一份合并的只读 `~/.ra3modxml/index.json` 列出当前实例与项目根。崩溃残留的实例会被下一个启动的实例顺手清理,不需要先重新打开同一个工作区。
|
||||
|
||||
**不需要安装 Node。** launcher 使用 VS Code 自带的 Electron 二进制(`ELECTRON_RUN_AS_NODE=1`)运行内置的 MCP Server,只有在该二进制缺失时才回退到 `PATH` 上的 `node`。launcher 在每次激活时重写,所以升级或移动 VS Code 都不会让已有的 MCP 配置失效。
|
||||
|
||||
MCP Server 本质上就是 stdio 上的 JSON-RPC,因此 agent 也可以完全不做 MCP 配置,直接把请求管道给 launcher。另有一个配套 CLI(`cli.js`,与 `mcpServer.js` 同在 `dist/agent/`):VS Code 运行时走实时索引,否则读导出的快照;需要元素上下文的命令会明确说明,而不是返回空结果。
|
||||
|
||||
完整设计与进度见 `docs/ai-agent-integration-plan.md`。
|
||||
|
||||
|
||||
@@ -35,6 +35,18 @@
|
||||
| 二期 C2 | 本地 HTTP `/get_asset_references` 与 MCP 工具接入 | ✅ 完成 |
|
||||
| 二期 C3 | 二期测试与文档 | ✅ 完成(264/264 测试通过) |
|
||||
|
||||
### 三期进度(2026-09-10)
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| 三期 0 | 讨论结论与进度落档 | ✅ 完成 |
|
||||
| 三期 1 | 运行时解析:无 Node 时用 VS Code Electron(`ELECTRON_RUN_AS_NODE`) | ✅ 完成 |
|
||||
| 三期 2 | 抽出 `src/agent/liveClient.ts`,MCP 与 CLI 共用转发/校验/负缓存 | ✅ 完成 |
|
||||
| 三期 3 | `instances/` 注册表 + 跨实例剪枝 + 只读 `index.json` 发现面 | ✅ 完成 |
|
||||
| 三期 4 | CLI 瘦身:live 优先、快照兜底、cwd 项目识别、补 `projects`/`get_asset_references` | ✅ 完成 |
|
||||
| 三期 5 | 三期测试与文档 | ✅ 完成(302 用例,299 通过 / 3 因沙箱跳过) |
|
||||
| 三期 6 | (可选)TCP → 命名管道 / UDS 传输 | ⬜ 未开始 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
@@ -770,4 +782,341 @@ VS Code 扩展进程内的 localServer ← 仅在"启用 AI Agent 访问"后启
|
||||
验证方式为:逐个 `node test/*.test.mjs`、直接调用 esbuild CLI 构建 dist、
|
||||
用管道驱动 `dist/agent/mcpServer.js` 做端到端 smoke test。
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# 第三部分:三期计划(讨论结论 + 实施)
|
||||
|
||||
> 讨论时间:2026-09-10
|
||||
> 主题:Agent 自发现 MCP 的现实边界、崩溃残留清理、CLI 定位、无 Node 环境
|
||||
|
||||
---
|
||||
|
||||
## 13. 讨论结论:自操作 Agent 能否自行发现并配置 MCP
|
||||
|
||||
### 现状(核实过代码)
|
||||
|
||||
`SKILL_MD` 全文不含 `~/.ra3modxml`、launcher 路径、CLI 或任何"工具不存在时怎么办"
|
||||
的说明。它的第一条指令是"调用 `get_status`"。因此当 skill 已加载但 MCP 工具
|
||||
不在工具列表里时,agent 只能得到 unknown tool,然后没有下一步。
|
||||
|
||||
三个独立缺口:
|
||||
|
||||
1. **没有发现面。** 磁盘上的文件名带 `sha1-12` 哈希与 slug
|
||||
(`endpoints/<slug>-<sha1-12>.json`、`snapshots/<slug>-<sha1-12>.json.gz`),
|
||||
只有读过本仓库源码的人才知道这套命名。agent 没有"读一个文件就知道全貌"的入口。
|
||||
2. **skill 与 MCP 是独立安装的。** `installAgentSkill` 装的是
|
||||
`~/.agents/skills/ra3-mod-xml/`,与 MCP 配置无关,所以"skill 在、MCP 不在"
|
||||
是常见状态,而这恰恰是最需要 fallback 的场景。
|
||||
3. **多数 harness 不允许 agent 给自己挂 MCP。** Claude Code 需要重启才能加载新的
|
||||
MCP server(相关 issue 与 SIGHUP workaround 见调研),Reddit 上也有"每次装
|
||||
MCP 都要手动重启"的反馈;反例是 LibreChat 的设置面板明确"take effect without
|
||||
a restart"。所以这是 **harness 相关**:agent 可以**写**配置,但通常不能让自己
|
||||
**当前会话**用上,只能为下一次会话准备好。
|
||||
|
||||
### 结论
|
||||
|
||||
- **skill 不应假设 MCP 已就绪**,而要给出"如何触达索引"的阶梯:
|
||||
1. MCP 工具已在工具列表 → 直接用;
|
||||
2. 否则读发现面清单;不存在 → 告诉用户去 VS Code 跑一次 Enable,**不要瞎猜**;
|
||||
存在 → 用 shell 走 stdio,或用 CLI;
|
||||
3. 若能写自己的 MCP 配置,可以提议代写,但必须告知**下个会话才生效**。
|
||||
- **"需要重启才生效"这类细节不必写进 skill。** 新一代自操作 agent 本来就该自己
|
||||
探测能力边界;skill 只需提示它"检查自己的能力范围并告知用户"。
|
||||
- **不要把各 harness 的配置路径写进 skill 文本。** 那些路径会变,写进 skill 等于
|
||||
把易变知识固化进 agent 上下文。逻辑留在 `setup.ts`
|
||||
(已有 `addMcpServerToConfigFile` / `commonMcpConfigTargets`),通过 CLI 暴露成
|
||||
`configure --harness <name>`;skill 只说"在用户同意后可以运行这条命令"。
|
||||
|
||||
---
|
||||
|
||||
## 14. 讨论结论:崩溃残留与 `instances/` 注册表
|
||||
|
||||
### 问题确认
|
||||
|
||||
核实结果:全仓只有两处 `rm`,都在 `stopAgentLocalServer`(即优雅 dispose)中。
|
||||
`endpoints/` **没有任何剪枝逻辑**。因此:
|
||||
|
||||
- **崩溃后文件永久残留**,只有下次打开**同一个工作区**才会覆盖同名文件;
|
||||
删掉项目、移动目录、改 SDK 路径后,旧文件永远躺着。
|
||||
- **PID 复用是真实风险。** Windows PID 会被回收;若残留 PID 恰好被无关进程占用,
|
||||
`isProcessAlive` 返回 true → 连接失败 → 5 秒负缓存 → 反复重试。不会给出错误
|
||||
答案(`responseProjectMismatch` 仍在),但会持续做无用功。
|
||||
|
||||
### 结论:不要"共享一个 JSON + 加锁合并"
|
||||
|
||||
共享单文件需要锁文件(Windows 上 `wx` + 退避 + 陈旧锁窃取)、需要
|
||||
read-modify-write 合并(否则并发写互相丢条目),而锁本身也会被崩溃留下
|
||||
——**为了修 A 的崩溃问题引入了 B 的崩溃问题**。
|
||||
|
||||
改用**写入方互不重叠**的结构:
|
||||
|
||||
```text
|
||||
~/.ra3modxml/
|
||||
instances/
|
||||
vscode-<pid>-<rand>.json ← 每个 VS Code 窗口一个,只有属主会写
|
||||
index.json ← 只读合并视图,供 agent/人发现
|
||||
snapshots/<slug>-<sha1-12>.json.gz
|
||||
skill-install.json
|
||||
```
|
||||
|
||||
- **无锁、无合并**:每实例只写自己那一个文件,天然无竞争。
|
||||
- **崩溃自愈**:任何实例激活时扫一遍 `instances/`,`isProcessAlive(pid) === false`
|
||||
的直接 unlink,不需要等"下次打开同一个工作区"。
|
||||
- **读方仍要校验**:liveness = PID 存活 **且** 能连上;PID 复用场景下连接失败即
|
||||
视为死,并顺带触发剪枝。
|
||||
- `index.json` 是纯只读派生物(由 `instances/` 生成),机器协调文件与人类/agent
|
||||
发现面分离。
|
||||
|
||||
`endpoints/` 保留为兼容读取路径,不再主动写入。
|
||||
|
||||
---
|
||||
|
||||
## 15. 讨论结论:CLI 定位与"稳定 IPC"
|
||||
|
||||
### 澄清:端口从未写进 harness 永久设置
|
||||
|
||||
写进配置的是 launcher:
|
||||
|
||||
```json
|
||||
{ "mcpServers": { "ra3-mod-xml": { "command": "~/.ra3modxml/ra3-mod-xml-mcp.cmd", "args": ["--project", "..."] } } }
|
||||
```
|
||||
|
||||
端口由 `instances/*.json` 桥接,每次开新会话时由 MCP server 现读。所以
|
||||
"临时端口会变"对 harness 完全透明。换命名管道/UDS 的收益不是"稳定性",而是:
|
||||
|
||||
- Windows 防火墙对监听 TCP socket 可能弹窗,命名管道不会;
|
||||
- 不占端口;
|
||||
- 权限模型更自然(管道可设 ACL;TCP loopback 任何本机进程都能连,现在靠 token)。
|
||||
|
||||
代价:Node 的 `fetch` 不支持 socketPath,客户端要从 `fetch` 换成
|
||||
`http.request({ socketPath })`(服务端 `listen(pipePath)` 即可,路由不用改)。
|
||||
|
||||
### CLI 定位:薄
|
||||
|
||||
CLI 只做两件事:**读磁盘快照** + **转发给扩展(live)**。不要在 CLI 里重新实现
|
||||
"高级功能",那等于写两遍。
|
||||
|
||||
**但要注意**:转发逻辑现在锁死在 `mcpServer.ts` 里
|
||||
(`tryLiveQuery` / `liveUrlForTool` / `responseProjectMismatch` / 负缓存)。
|
||||
若 CLI 再写一遍就是重复。因此必须先抽 `src/agent/liveClient.ts`,让
|
||||
`mcpServer.ts` 与 `cli.ts` **同时**依赖它:
|
||||
|
||||
- 换传输层时只改一个文件;
|
||||
- `projectDir` 校验、负缓存等安全逻辑不会在两个入口之间漂移。
|
||||
|
||||
---
|
||||
|
||||
## 16. 讨论结论:无 Node 环境(已验证)
|
||||
|
||||
### 问题
|
||||
|
||||
- 不能假定用户装了 Node。
|
||||
- `writeLauncher` 生成的是裸 `node "<server>" ...`,所以**没装 Node 的用户
|
||||
点完 Enable 之后 MCP 起不来**,而且不会报"缺 node"。这是**已存在的缺口**,
|
||||
不是未来风险。
|
||||
- 打包自包含或原生二进制是否必要?
|
||||
|
||||
### 结论:不需要打包,VS Code 自带运行时,只是还没用它
|
||||
|
||||
VS Code 的 Electron 二进制可以当 Node 用:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
set ELECTRON_RUN_AS_NODE=1
|
||||
"C:\...\Microsoft VS Code\Code.exe" "<ext>\dist\agent\mcpServer.js" --project "D:\..."
|
||||
```
|
||||
|
||||
**本机实测(2026-09-10,VS Code 1.135.0 / Electron 42.8.1)**:
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| `ELECTRON_RUN_AS_NODE=1` 生效 | ✅ `process.versions.electron = 42.8.1` |
|
||||
| Node 版本 | `24.18.1`(系统 Node 为 24.16.0) |
|
||||
| 必需模块(`fs`/`fs/promises`/`path`/`os`/`http`/`readline`/`zlib`/`crypto`/`util`/`net`/`child_process`) | ✅ 全部可载入 |
|
||||
| `node:test` | ✅ 可载入 |
|
||||
| `forwardRefs.parseLoadedXml` | ✅ `elements=2` |
|
||||
| `localServer.startLocalServer` + HTTP 请求 | ✅ 正常返回 |
|
||||
| `zlib` gzip/gunzip 往返 | ✅ |
|
||||
| **打包后的 `dist/agent/mcpServer.js` 完整 stdio 会话** | ✅ `initialize` / `tools/list`(9 个工具) / `tools/call get_status` 全部正确 |
|
||||
|
||||
因此:
|
||||
|
||||
- **零外部依赖**,只装 VS Code 就够;
|
||||
- 路径来自 `process.execPath`,自动适配 Code / Insiders / VSCodium / 各平台;
|
||||
- VS Code 升级后路径变了也没关系——扩展每次激活重写 launcher,而 launcher 本身
|
||||
在 `~/.ra3modxml/` 下路径稳定;
|
||||
- 需要 fallback:Electron 二进制被移动/卸载时回退到 PATH 上的 `node`。
|
||||
|
||||
已知代价(实测未测,需留意的边界):
|
||||
|
||||
- Electron 以 Node 模式启动比真 Node 慢(握手延迟增加,对 MCP 应无碍);
|
||||
- 远程/WSL/容器场景下 `process.execPath` 是 server 端二进制,MCP 必须跑在远端
|
||||
—— 独立边界问题,本期不解决。
|
||||
|
||||
### 如何在"本机有 Node"的前提下测试"无 Node"
|
||||
|
||||
不需要真的卸载 Node。要做的是**证明 Electron 路径可以独立工作**,即整条链路
|
||||
不依赖"PATH 上能解析到 `node`":
|
||||
|
||||
1. 直接只用 Electron 二进制驱动 `dist/agent/mcpServer.js`(已做,见上表);
|
||||
2. 让解析出的 launcher **只用** Electron,不回落;
|
||||
3. 断言 launcher 文本中不出现裸 `node`(当 Electron 可用时)。
|
||||
|
||||
这三点都在本环境可做,无需移除 Node。
|
||||
|
||||
---
|
||||
|
||||
## 17. 三期实施清单
|
||||
|
||||
| 优先级 | 内容 | 理由 |
|
||||
|---|---|---|
|
||||
| 1 | 运行时解析(`src/agent/runtime.ts`)+ launcher 优先 Electron | 现在是"配好了但可能起不来",最伤 |
|
||||
| 2 | 抽 `src/agent/liveClient.ts` | 后续所有传输层改动的前提;避免转发逻辑写两遍 |
|
||||
| 3 | `instances/` + 跨实例剪枝 + 只读 `index.json` | 修崩溃残留,兼做 agent 发现面 |
|
||||
| 4 | CLI 瘦身(live 优先 / 快照兜底 / cwd 识别 / `projects`) | 让"没有 MCP 的 agent"也能用 |
|
||||
| 5 | 传输层 TCP → 命名管道 / UDS | 收益中等,需 2 完成,可缓 |
|
||||
|
||||
---
|
||||
|
||||
## 18. 三期实施结果
|
||||
|
||||
全部完成。
|
||||
|
||||
### 新增 / 修改文件
|
||||
|
||||
```text
|
||||
新增:
|
||||
src/agent/runtime.ts 运行时解析 + launcher 脚本生成(纯函数,可测)
|
||||
src/agent/liveClient.ts 共享 live 转发层(MCP 与 CLI 共用)
|
||||
src/agent/instances.ts instances/ 注册表、剪枝、只读 index.json
|
||||
test/agentRuntime.test.mjs
|
||||
test/agentLiveClient.test.mjs
|
||||
test/agentInstances.test.mjs
|
||||
test/agentCli.test.mjs
|
||||
test/agentLiveE2E.test.mjs
|
||||
tools/probe-electron-node.cjs Electron-as-Node 能力探针(保留为证据)
|
||||
tools/serve-fake-index.cjs 假 live 索引服务器(手工 smoke 用)
|
||||
|
||||
修改:
|
||||
src/agent/setup.ts writeLauncher 改用 runtime + launcherScript,
|
||||
返回 { path, runtime, nodeFree }
|
||||
src/agent/mcpServer.ts 改用 LiveClient;新增 list_projects;
|
||||
启动时剪枝崩溃实例;require.main 守卫
|
||||
src/agent/cli.ts 重写为薄客户端:live 优先 / 快照兜底 /
|
||||
cwd 项目识别 / projects / outgoing
|
||||
src/extension.ts instances/ 写入与剪枝、index.json 刷新、
|
||||
launcher 激活时刷新、nodeFree 警告
|
||||
```
|
||||
|
||||
### 关键行为
|
||||
|
||||
**无 Node 运行(三期 1)**
|
||||
|
||||
- launcher 优先用 VS Code 的 Electron 二进制(`ELECTRON_RUN_AS_NODE=1`),
|
||||
Node 只作为"Electron 二进制不存在"时的兜底:
|
||||
`if not exist "%RA3_RUNTIME%" goto :ra3_node`。
|
||||
- 用 `goto` 而不是 `if (...)` 块:批处理里块内的 `%errorlevel%` 在**解析时**展开,
|
||||
会导致退出码错误。
|
||||
- 扩展激活时会重写 launcher(不只是 Enable 时),所以 VS Code 升级换了路径
|
||||
也能自动跟上。
|
||||
- 解析不到 Electron 时会警告用户"launcher 将依赖 PATH 上的 Node"。
|
||||
|
||||
**`instances/` 注册表(三期 3)**
|
||||
|
||||
```text
|
||||
~/.ra3modxml/
|
||||
instances/vscode-<pid>-<rand>.json 每窗口一个,只有属主写
|
||||
index.json 只读合并视图(不含 token)
|
||||
endpoints/<slug>-<sha1-12>.json 兼容读取,不再主动写
|
||||
snapshots/…
|
||||
skill-install.json
|
||||
```
|
||||
|
||||
- **无锁无合并**:写入方互不重叠,没有 read-modify-write 竞争。
|
||||
- **崩溃自愈**:任何实例启动时剪掉 PID 已死的条目,不需要等同一工作区重开。
|
||||
- **只剪确定的死 PID**:`isProcessAlive` 对未知/缺失 PID 返回 true,
|
||||
所以老格式文件不会被误删。
|
||||
- `index.json` 是派生物,只用于发现;断言过其中不含 token。
|
||||
|
||||
**共享 live 层(三期 2)**
|
||||
|
||||
`liveClient.ts` 现在同时被 MCP 与 CLI 使用,集中了:
|
||||
|
||||
- 请求永远带 `?project=`(否则会被活动编辑器的项目回答);
|
||||
- `index.projectDir` 不匹配就拒答;
|
||||
- 死 PID 视为失效;
|
||||
- 失败后 5 秒负缓存(可注入时钟,测试可确定性验证);
|
||||
- 端点发现顺序:按项目文件 → 任何 `projects` 含该项目的实例 → 全局兜底指针。
|
||||
|
||||
**薄 CLI(三期 4)**
|
||||
|
||||
- `outgoing` / `projects` 是 live-only:不可用时返回**明确原因**,
|
||||
而不是空结果(空结果会被 agent 读成"没有引用")。
|
||||
- 项目解析顺序:`--project` → `--snapshot` → 从 cwd 向上找 mod 项目根。
|
||||
- 退出码:0 成功 / 2 用法错误 / 3 不可用。
|
||||
|
||||
### 已知限制
|
||||
|
||||
- `get_asset_references` 不计算 `xai:joinAction`(`Replace`/`Remove`)合并后的
|
||||
有效值,需要调用方自行确认。
|
||||
- 传输层仍是 loopback HTTP(临时端口 + token)。换命名管道/UDS 的收益是
|
||||
免防火墙弹窗与更自然的权限模型,代价是客户端要从 `fetch` 改为
|
||||
`http.request({ socketPath })`;因为转发逻辑已集中,改动面只剩
|
||||
`liveClient.ts`。
|
||||
- 远程 / WSL / 容器场景下 `process.execPath` 是服务端二进制,MCP 必须跑在
|
||||
远端;本期未处理。
|
||||
- CLI 在"无 Node 且无 VS Code"的环境下无法运行(这是逻辑必然:它需要一个
|
||||
运行时)。有 VS Code 时用 Electron 二进制即可。
|
||||
|
||||
---
|
||||
|
||||
## 19. 三期测试与验证
|
||||
|
||||
```
|
||||
npx tsc --noEmit 通过
|
||||
test/*.test.mjs 37 个文件 / 302 个用例,299 通过,3 跳过
|
||||
```
|
||||
|
||||
跳过的 3 个都是同一原因:本沙箱禁止从 Node 进程 spawn shell(`EPERM`),
|
||||
所以"真正拉起 launcher / CLI 子进程"的集成用例会优雅跳过并给出原因。
|
||||
它们在本机正常运行时会执行。
|
||||
|
||||
### Electron-as-Node 实测证据
|
||||
|
||||
`tools/probe-electron-node.cjs`(VS Code 1.135.0 / Electron 42.8.1,Windows):
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| `ELECTRON_RUN_AS_NODE=1` 生效 | ✅ `process.versions.electron = 42.8.1` |
|
||||
| Node 版本 | `24.18.1`(系统 Node 24.16.0) |
|
||||
| 必需模块 | ✅ `fs`/`fs/promises`/`path`/`os`/`http`/`readline`/`zlib`/`crypto`/`util`/`net`/`child_process` |
|
||||
| `node:test` | ✅ 可载入 |
|
||||
| `parseLoadedXml` | ✅ |
|
||||
| `startLocalServer` + HTTP 请求 | ✅ |
|
||||
| `zlib` gzip/gunzip 往返 | ✅ |
|
||||
| **打包后 `dist/agent/mcpServer.js` 完整 stdio 会话** | ✅ initialize / tools/list(10 个) / tools/call |
|
||||
|
||||
### 无 Node 运行的证明方式
|
||||
|
||||
不是卸载 Node,而是证明整条链路不依赖"PATH 上能解析到 node":
|
||||
|
||||
1. 用 `cmd /c` + `ELECTRON_RUN_AS_NODE=1` 直接驱动 `dist/agent/mcpServer.js`
|
||||
→ 完整 MCP 会话成功;
|
||||
2. 生成的 launcher 里把 **Node 兜底指向一个不存在的路径**,会话仍然成功
|
||||
→ 证明走的是 Electron 分支;
|
||||
3. 断言 launcher 文本:Windows 下 Node 分支只能通过
|
||||
`if not exist "%RA3_RUNTIME%" goto :ra3_node` 到达。
|
||||
|
||||
### 其他已验证行为
|
||||
|
||||
- **CLI 会自然退出**:实测一次 live 查询后进程 59ms 内自然退出,
|
||||
`fetch` 的 keep-alive 不会吊住 CLI(否则对 agent 是致命的可用性问题)。
|
||||
- **live 端到端**:`test/agentLiveE2E.test.mjs` 启动真实 `startLocalServer`,
|
||||
按扩展的方式写 instance/endpoint/manifest,然后用**同一个 `LiveClient`**
|
||||
跑通全部工具,包括 `get_asset_references` 的 `definedIn` 继承溯源。
|
||||
- **跨项目隔离**:为第三个项目创建的客户端在只有 A/B 实例时返回 null,
|
||||
不会回落到 A 的数据。
|
||||
- **发现降级**:删掉 `endpoints/` 后仍能通过 `instances/` 找到实例。
|
||||
- **崩溃剪枝**:插入一个 PID 必死的实例,剪枝后另一个实例仍正常工作;
|
||||
清理本窗口实例不影响其他窗口。
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "ra3-mod-xml",
|
||||
"displayName": "%ra3modxml.displayName%",
|
||||
"description": "%ra3modxml.description%",
|
||||
"version": "0.1.25",
|
||||
"version": "0.1.26",
|
||||
"publisher": "lanyi",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"icon": "images/icon.png",
|
||||
|
||||
+258
-94
@@ -1,16 +1,37 @@
|
||||
/**
|
||||
* Minimal CLI for querying an exported RA3 Mod XML agent snapshot.
|
||||
* Thin CLI for RA3 Mod XML index queries.
|
||||
*
|
||||
* This is intentionally not installed into PATH. It is meant for scripts,
|
||||
* debugging, and as a reference for MCP tool implementations.
|
||||
* Deliberately thin: it does **not** reimplement the query surface. It does
|
||||
* exactly two things.
|
||||
*
|
||||
* Usage examples:
|
||||
* node out/agent/cli.js --project D:/Mods/Example status
|
||||
* node out/agent/cli.js --snapshot /path/to/snapshot.json.gz find ExampleUnit
|
||||
* node out/agent/cli.js --project D:/Mods/Example refs ExampleUnit GameObject
|
||||
* 1. Forward the call to the live VS Code extension through the shared
|
||||
* `LiveClient` (same transport, project pinning and validation as the MCP
|
||||
* server, so the two entry points cannot drift apart).
|
||||
* 2. Fall back to the on-disk snapshot exported by the extension when no live
|
||||
* instance is reachable.
|
||||
*
|
||||
* Features that need the DOM (element-level provenance) are live-only; the
|
||||
* CLI reports that clearly instead of returning an empty result that would
|
||||
* look like "no references exist".
|
||||
*
|
||||
* The CLI is not installed into PATH. It is reached through an absolute path,
|
||||
* normally by an agent following the skill's discovery instructions.
|
||||
*
|
||||
* Usage:
|
||||
* node dist/agent/cli.js status
|
||||
* node dist/agent/cli.js find <id> [type]
|
||||
* node dist/agent/cli.js refs <id> [type]
|
||||
* node dist/agent/cli.js outgoing <id> [type] [--depth N] [--target-types A,B]
|
||||
* node dist/agent/cli.js list <type> [prefix]
|
||||
* node dist/agent/cli.js active <file>
|
||||
* node dist/agent/cli.js define <name>
|
||||
* node dist/agent/cli.js resolve <source>
|
||||
* node dist/agent/cli.js projects
|
||||
*/
|
||||
|
||||
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
|
||||
import { resolve } from "node:path";
|
||||
import { findProjectRootUpward } from "../projectRoot";
|
||||
import { LiveClient, type LiveClientOptions } from "./liveClient";
|
||||
import {
|
||||
findAssets,
|
||||
findDefine,
|
||||
@@ -20,32 +41,63 @@ import {
|
||||
resolveIncludeSource,
|
||||
statusFromSnapshot,
|
||||
} from "./query";
|
||||
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
|
||||
import type { AgentIndexSnapshot } from "./types";
|
||||
|
||||
interface CliOptions {
|
||||
snapshotPath: string | null;
|
||||
const EXIT_OK = 0;
|
||||
const EXIT_USAGE = 2;
|
||||
const EXIT_UNAVAILABLE = 3;
|
||||
|
||||
export interface CliOptions {
|
||||
projectDir: string | null;
|
||||
snapshotPath: string | null;
|
||||
agentHome: string | undefined;
|
||||
command: string;
|
||||
args: string[];
|
||||
depth: number | undefined;
|
||||
targetTypes: string[];
|
||||
maxEdges: number | undefined;
|
||||
includeUnresolved: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
export function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
snapshotPath: null,
|
||||
projectDir: null,
|
||||
snapshotPath: null,
|
||||
agentHome: undefined,
|
||||
command: "status",
|
||||
args: [],
|
||||
depth: undefined,
|
||||
targetTypes: [],
|
||||
maxEdges: undefined,
|
||||
includeUnresolved: false,
|
||||
};
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--snapshot" || arg === "-s") {
|
||||
options.snapshotPath = argv[++i] ?? null;
|
||||
} else if (arg === "--project" || arg === "-p") {
|
||||
if (arg === "--project" || arg === "-p") {
|
||||
options.projectDir = argv[++i] ?? null;
|
||||
} else if (arg === "--snapshot" || arg === "-s") {
|
||||
options.snapshotPath = argv[++i] ?? null;
|
||||
} else if (arg === "--agent-home") {
|
||||
options.agentHome = argv[++i] ?? undefined;
|
||||
} else if (arg === "--depth" || arg === "-d") {
|
||||
const raw = argv[++i];
|
||||
options.depth = raw != null ? Number(raw) : undefined;
|
||||
} else if (arg === "--target-types" || arg === "-t") {
|
||||
options.targetTypes = (argv[++i] ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} else if (arg === "--max-edges") {
|
||||
const raw = argv[++i];
|
||||
options.maxEdges = raw != null ? Number(raw) : undefined;
|
||||
} else if (arg === "--include-unresolved") {
|
||||
options.includeUnresolved = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
options.command = "help";
|
||||
} else if (arg.startsWith("-")) {
|
||||
// ignore unknown flags
|
||||
// Ignore unknown flags rather than failing an agent's probing call.
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
@@ -58,102 +110,214 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`RA3 Mod XML agent snapshot CLI
|
||||
process.stdout.write(`RA3 Mod XML agent CLI
|
||||
|
||||
Usage:
|
||||
node out/agent/cli.js --project <dir> <command> [args...]
|
||||
node out/agent/cli.js --snapshot <file> <command> [args...]
|
||||
node cli.js [--project <dir>] <command> [args...]
|
||||
node cli.js [--snapshot <file>] <command> [args...]
|
||||
|
||||
Project resolution order:
|
||||
1. --project
|
||||
2. --snapshot
|
||||
3. nearest mod project root above the current directory
|
||||
|
||||
Commands:
|
||||
status
|
||||
find <id> [type]
|
||||
refs <id> [type]
|
||||
list <type> [prefix]
|
||||
active <file>
|
||||
define <name>
|
||||
resolve <source>
|
||||
status index state (+ source: live or snapshot)
|
||||
find <id> [type] asset definitions
|
||||
refs <id> [type] incoming semantic references
|
||||
outgoing <id> [type] outgoing reference edges (live only)
|
||||
--depth N --target-types A,B --max-edges N --include-unresolved
|
||||
list <type> [prefix] assets of one type
|
||||
active <file> whether a file is in an indexed stream
|
||||
define <name> $DEFINE constants
|
||||
resolve <source> Include source candidate
|
||||
projects indexed project roots (live only)
|
||||
help
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.command === "help") {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const snapshotPath =
|
||||
options.snapshotPath ??
|
||||
(options.projectDir ? snapshotPathForProject(options.projectDir) : null);
|
||||
if (!snapshotPath) {
|
||||
console.error("No --project or --snapshot provided.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const snapshot = await readSnapshotFile(snapshotPath);
|
||||
if (!snapshot) {
|
||||
console.error(`Snapshot not found or unreadable: ${snapshotPath}`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
/** Resolves the project directory, inferring it from cwd when not given. */
|
||||
export function resolveProjectDir(options: CliOptions): string | null {
|
||||
if (options.projectDir) return resolve(options.projectDir);
|
||||
const inferred = findProjectRootUpward(process.cwd());
|
||||
return inferred;
|
||||
}
|
||||
|
||||
/** Writes JSON to stdout and returns the exit code. */
|
||||
function emit(value: unknown): number {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
function fail(message: string, code = EXIT_USAGE): number {
|
||||
process.stderr.write(`${message}\n`);
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Live-only commands, reported explicitly when live is unreachable. */
|
||||
export const LIVE_ONLY = new Set(["outgoing", "projects"]);
|
||||
|
||||
export function liveArgsFor(options: CliOptions): Record<string, unknown> {
|
||||
const [a, b] = options.args;
|
||||
switch (options.command) {
|
||||
case "status":
|
||||
console.log(JSON.stringify(statusFromSnapshot(snapshot), null, 2));
|
||||
break;
|
||||
case "find":
|
||||
if (!a) {
|
||||
console.error("find requires an id.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify(findAssets(snapshot, a, b), null, 2));
|
||||
break;
|
||||
return { id: a, type: b };
|
||||
case "refs":
|
||||
if (!a) {
|
||||
console.error("refs requires an id.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify(findReferenceGroups(snapshot, a, b), null, 2));
|
||||
break;
|
||||
return { id: a, type: b };
|
||||
case "outgoing":
|
||||
return {
|
||||
id: a,
|
||||
type: b,
|
||||
depth: options.depth,
|
||||
targetTypes: options.targetTypes?.length ? options.targetTypes : undefined,
|
||||
maxEdges: options.maxEdges,
|
||||
includeUnresolved: options.includeUnresolved ? true : undefined,
|
||||
};
|
||||
case "list":
|
||||
if (!a) {
|
||||
console.error("list requires a type.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify(listAssetsByType(snapshot, a, b ?? ""), null, 2));
|
||||
break;
|
||||
return { type: a, prefix: b };
|
||||
case "active":
|
||||
if (!a) {
|
||||
console.error("active requires a file path.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify({ active: isFileActive(snapshot, a) }, null, 2));
|
||||
break;
|
||||
return { path: a };
|
||||
case "define":
|
||||
if (!a) {
|
||||
console.error("define requires a name.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify(findDefine(snapshot, a), null, 2));
|
||||
break;
|
||||
return { name: a };
|
||||
case "resolve":
|
||||
if (!a) {
|
||||
console.error("resolve requires a source.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify(resolveIncludeSource(snapshot, a), null, 2));
|
||||
break;
|
||||
return { source: a };
|
||||
default:
|
||||
console.error(`Unknown command: ${options.command}`);
|
||||
process.exitCode = 2;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
/** Maps a CLI command to the live/MCP tool name. */
|
||||
export function toolNameFor(command: string): string {
|
||||
switch (command) {
|
||||
case "find":
|
||||
return "find_asset";
|
||||
case "refs":
|
||||
return "find_references";
|
||||
case "outgoing":
|
||||
return "get_asset_references";
|
||||
case "list":
|
||||
return "list_assets_by_type";
|
||||
case "active":
|
||||
return "is_file_active";
|
||||
case "define":
|
||||
return "find_define";
|
||||
case "resolve":
|
||||
return "resolve_include";
|
||||
case "projects":
|
||||
return "list_projects";
|
||||
default:
|
||||
return "get_status";
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the command against the on-disk snapshot. */
|
||||
function runSnapshotCommand(
|
||||
options: CliOptions,
|
||||
snapshot: AgentIndexSnapshot,
|
||||
): number {
|
||||
const [a, b] = options.args;
|
||||
switch (options.command) {
|
||||
case "status":
|
||||
return emit({ source: "snapshot", index: statusFromSnapshot(snapshot) });
|
||||
case "find":
|
||||
if (!a) return fail("find requires an id.");
|
||||
return emit(findAssets(snapshot, a, b));
|
||||
case "refs":
|
||||
if (!a) return fail("refs requires an id.");
|
||||
return emit(findReferenceGroups(snapshot, a, b));
|
||||
case "list":
|
||||
if (!a) return fail("list requires a type.");
|
||||
return emit(listAssetsByType(snapshot, a, b ?? ""));
|
||||
case "active":
|
||||
if (!a) return fail("active requires a file path.");
|
||||
return emit({ active: isFileActive(snapshot, a) });
|
||||
case "define":
|
||||
if (!a) return fail("define requires a name.");
|
||||
return emit(findDefine(snapshot, a.replace(/^\$/, "")));
|
||||
case "resolve":
|
||||
if (!a) return fail("resolve requires a source.");
|
||||
return emit(resolveIncludeSource(snapshot, a));
|
||||
default:
|
||||
return fail(`Unknown command: ${options.command}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Explains why a live-only command could not run. */
|
||||
function liveOnlyUnavailable(options: CliOptions): number {
|
||||
return emit({
|
||||
index: { state: "no_index" },
|
||||
source: "unavailable",
|
||||
error:
|
||||
options.command === "outgoing"
|
||||
? "get_asset_references requires a live index: the element context it reports is not stored in the on-disk snapshot. Open the project in VS Code with AI Agent access enabled, then retry."
|
||||
: "list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.",
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.command === "help") {
|
||||
printHelp();
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
const projectDir = resolveProjectDir(options);
|
||||
|
||||
// Live first: the extension's in-memory index is the most complete source.
|
||||
if (!options.snapshotPath) {
|
||||
const liveOptions: LiveClientOptions = {
|
||||
projectDir,
|
||||
agentHome: options.agentHome,
|
||||
};
|
||||
const client = new LiveClient(liveOptions);
|
||||
const result = await client.query(
|
||||
toolNameFor(options.command),
|
||||
liveArgsFor(options),
|
||||
);
|
||||
if (result?.mismatched) {
|
||||
return emit({
|
||||
index: { state: "error" },
|
||||
source: "live",
|
||||
error: `The live server answered for a different project than "${projectDir}"; refusing the result.`,
|
||||
});
|
||||
}
|
||||
if (result) {
|
||||
return emit({ source: "live", ...(result.payload as object) });
|
||||
}
|
||||
// No live index: fall through to the snapshot when the command allows it.
|
||||
if (LIVE_ONLY.has(options.command)) return liveOnlyUnavailable(options);
|
||||
}
|
||||
|
||||
// Snapshot fallback.
|
||||
const snapshotPath =
|
||||
options.snapshotPath ??
|
||||
(projectDir ? snapshotPathForProject(projectDir, options.agentHome) : null);
|
||||
if (!snapshotPath) {
|
||||
return fail(
|
||||
"No project found. Pass --project <dir>, --snapshot <file>, or run from inside a mod project.",
|
||||
EXIT_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
const snapshot = await readSnapshotFile(snapshotPath);
|
||||
if (!snapshot) {
|
||||
return fail(
|
||||
`No live VS Code instance and no readable snapshot at ${snapshotPath}. Open the project in VS Code with AI Agent access enabled, or run the "Export AI Agent index snapshot" command.`,
|
||||
EXIT_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
return runSnapshotCommand(options, snapshot);
|
||||
}
|
||||
|
||||
// Only run the CLI when executed directly, so the module stays importable by
|
||||
// tests (same guard as the MCP server).
|
||||
if (typeof require !== "undefined" && require.main === module) {
|
||||
void main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exitCode = EXIT_UNAVAILABLE;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Live-instance registry.
|
||||
*
|
||||
* Each VS Code window that enables AI Agent access writes **its own** file
|
||||
* under `instances/`. Two properties follow from that:
|
||||
*
|
||||
* - **No locking and no merging.** Writers never touch each other's files, so
|
||||
* there is no read-modify-write race to guard. An earlier design that
|
||||
* shared a single JSON file would have needed a lock file (itself another
|
||||
* thing a crash can leave behind) plus a merge step.
|
||||
* - **Crash recovery does not wait for the same workspace.** Any instance can
|
||||
* prune entries whose recorded PID is dead, so a crashed window is cleaned
|
||||
* up the next time *any* VS Code window with the extension activates.
|
||||
*
|
||||
* A merged, read-only `index.json` is derived from the instance files purely
|
||||
* for discovery (agents/humans looking at the directory), so the machine
|
||||
* coordination files and the human-readable view stay separate.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency.
|
||||
*/
|
||||
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { isProcessAlive, type AgentEndpoint } from "./endpoint";
|
||||
import { defaultAgentHome, snapshotBaseName } from "./snapshot";
|
||||
|
||||
/** One live extension-host instance. */
|
||||
export interface AgentInstance extends AgentEndpoint {
|
||||
/** Unique per window; also the file name stem. */
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
/** Discovery manifest derived from all live instances. */
|
||||
export interface AgentIndexManifest {
|
||||
schemaVersion: number;
|
||||
generatedAt: string;
|
||||
instances: Array<{
|
||||
instanceId: string;
|
||||
processId?: number;
|
||||
url: string;
|
||||
/** Tokens are intentionally omitted: the manifest is for discovery. */
|
||||
projects: string[];
|
||||
}>;
|
||||
projects: string[];
|
||||
}
|
||||
|
||||
export const INSTANCE_SCHEMA_VERSION = 1;
|
||||
|
||||
/** Directory holding one file per live extension host. */
|
||||
export function instancesDir(agentHome = defaultAgentHome()): string {
|
||||
return join(agentHome, "instances");
|
||||
}
|
||||
|
||||
/** Path to the merged discovery manifest. */
|
||||
export function manifestPath(agentHome = defaultAgentHome()): string {
|
||||
return join(agentHome, "index.json");
|
||||
}
|
||||
|
||||
/** File name for one instance. */
|
||||
export function instanceFileName(instanceId: string): string {
|
||||
return `vscode-${instanceId}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a process-unique instance id. Combining the PID with a random suffix
|
||||
* keeps two windows of the same process id from colliding across restarts.
|
||||
*/
|
||||
export function makeInstanceId(pid = process.pid): string {
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
return `${pid}-${rand}`;
|
||||
}
|
||||
|
||||
/** Writes this instance's own file. */
|
||||
export async function writeInstance(
|
||||
instance: AgentInstance,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<string> {
|
||||
const file = join(instancesDir(agentHome), instanceFileName(instance.instanceId));
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, `${JSON.stringify(instance, null, 2)}\n`, "utf8");
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Reads every instance file, skipping malformed ones. */
|
||||
export async function readInstances(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<AgentInstance[]> {
|
||||
const dir = instancesDir(agentHome);
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: AgentInstance[] = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith(".json")) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(join(dir, name), "utf8"),
|
||||
) as AgentInstance;
|
||||
if (parsed?.url && parsed?.token) {
|
||||
// Fall back to the file name when an older file lacks the field.
|
||||
parsed.instanceId ??= name.replace(/^vscode-/, "").replace(/\.json$/, "");
|
||||
out.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable/corrupt entries.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Removes this instance's own file. */
|
||||
export async function clearInstance(
|
||||
instanceId: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<void> {
|
||||
await rm(join(instancesDir(agentHome), instanceFileName(instanceId)), {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
export interface PruneResult {
|
||||
removed: string[];
|
||||
kept: AgentInstance[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes instance files whose recorded PID is no longer alive.
|
||||
*
|
||||
* This is how crashes are cleaned up without waiting for the same workspace to
|
||||
* be reopened. Only clearly-dead PIDs are pruned: `isProcessAlive` treats an
|
||||
* unknown or unparseable PID as alive, so an older file that predates the
|
||||
* `processId` field is never deleted by mistake.
|
||||
*/
|
||||
export async function pruneInstances(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<PruneResult> {
|
||||
const instances = await readInstances(agentHome);
|
||||
const removed: string[] = [];
|
||||
const kept: AgentInstance[] = [];
|
||||
for (const instance of instances) {
|
||||
if (isProcessAlive(instance.processId)) {
|
||||
kept.push(instance);
|
||||
} else {
|
||||
removed.push(instance.instanceId);
|
||||
await clearInstance(instance.instanceId, agentHome).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return { removed, kept };
|
||||
}
|
||||
|
||||
/** Collects every project root across live instances. */
|
||||
export function projectsOf(instances: readonly AgentInstance[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const instance of instances) {
|
||||
const candidates = [
|
||||
...(instance.projects ?? []),
|
||||
...(instance.projectDir ? [instance.projectDir] : []),
|
||||
];
|
||||
for (const project of candidates) {
|
||||
const key = project.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(project);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the merged read-only manifest from the live instances.
|
||||
* Best-effort: failures are swallowed because the manifest is a convenience,
|
||||
* not a correctness requirement (readers can always scan `instances/`).
|
||||
*/
|
||||
export async function writeManifest(
|
||||
instances: readonly AgentInstance[],
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<AgentIndexManifest> {
|
||||
const manifest: AgentIndexManifest = {
|
||||
schemaVersion: INSTANCE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
instances: instances.map((instance) => ({
|
||||
instanceId: instance.instanceId,
|
||||
processId: instance.processId,
|
||||
url: instance.url,
|
||||
projects: [
|
||||
...(instance.projects ?? []),
|
||||
...(instance.projectDir ? [instance.projectDir] : []),
|
||||
],
|
||||
})),
|
||||
projects: projectsOf(instances),
|
||||
};
|
||||
try {
|
||||
const file = manifestPath(agentHome);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
} catch {
|
||||
// Discovery only: never fail the caller over this.
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/** Reads the discovery manifest, or null when absent/malformed. */
|
||||
export async function readManifest(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<AgentIndexManifest | null> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
await readFile(manifestPath(agentHome), "utf8"),
|
||||
) as AgentIndexManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot path for a project, re-exported for discovery convenience. */
|
||||
export function projectKey(projectDir: string): string {
|
||||
return snapshotBaseName(projectDir);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Shared live-index client used by both the MCP server and the CLI.
|
||||
*
|
||||
* Keeping this in one place matters because the forwarding path carries safety
|
||||
* logic that must not drift between entry points:
|
||||
*
|
||||
* - the requested project is always pinned on the request, so the server
|
||||
* cannot answer from whichever project its active editor points at;
|
||||
* - a response reporting a different `projectDir` is refused rather than
|
||||
* shown, because a plausible wrong answer is worse than no answer;
|
||||
* - a dead extension-host PID marks the instance file stale;
|
||||
* - failed attempts are negatively cached so every tool call does not pay a
|
||||
* connection timeout.
|
||||
*
|
||||
* Transport is currently loopback HTTP. Swapping it for a named pipe / Unix
|
||||
* domain socket only requires changing this file.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency.
|
||||
*/
|
||||
|
||||
import {
|
||||
isProcessAlive,
|
||||
readEndpoint,
|
||||
readEndpointForProject,
|
||||
sameProject,
|
||||
type AgentEndpoint,
|
||||
} from "./endpoint";
|
||||
import { readInstances } from "./instances";
|
||||
|
||||
/** Cooldown after a failed live attempt, to avoid a probe per tool call. */
|
||||
const LIVE_RETRY_COOLDOWN_MS = 5000;
|
||||
/** Per-request timeout for a live query. */
|
||||
const LIVE_TIMEOUT_MS = 1500;
|
||||
|
||||
export interface LiveQueryResult {
|
||||
payload: unknown;
|
||||
/** True when the live server answered but for a different project. */
|
||||
mismatched: boolean;
|
||||
/** Which endpoint produced the answer (for diagnostics). */
|
||||
endpoint: AgentEndpoint;
|
||||
}
|
||||
|
||||
/** Normalizes a path for case/separator-insensitive comparison. */
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a live response that belongs to a different project than the one
|
||||
* the client was started for. See docs/ai-agent-integration-plan.md §10 for
|
||||
* why this guard exists: in a multi-window setup a client configured for
|
||||
* project A could otherwise silently receive project B's index.
|
||||
*/
|
||||
export function responseProjectMismatch(
|
||||
payload: unknown,
|
||||
projectDir: string | null,
|
||||
): boolean {
|
||||
if (!projectDir) return false;
|
||||
const index = (payload as { index?: { projectDir?: string } } | null)?.index;
|
||||
const reported = index?.projectDir;
|
||||
if (!reported) return false;
|
||||
return normalizePath(reported) !== normalizePath(projectDir);
|
||||
}
|
||||
|
||||
/** Tools that can be answered by the live index and their HTTP paths. */
|
||||
const TOOL_PATHS: Record<string, string> = {
|
||||
get_status: "/status",
|
||||
find_asset: "/find_asset",
|
||||
find_references: "/find_references",
|
||||
get_asset_references: "/get_asset_references",
|
||||
list_assets_by_type: "/list_assets",
|
||||
is_file_active: "/is_file_active",
|
||||
find_define: "/find_define",
|
||||
resolve_include: "/resolve_include",
|
||||
list_projects: "/projects",
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the live request URL for a tool call, always pinning the project.
|
||||
* Returns null for tools the live server does not serve.
|
||||
*/
|
||||
export function liveUrlForTool(
|
||||
endpointUrl: string,
|
||||
projectDir: string | null,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): string | null {
|
||||
const path = TOOL_PATHS[toolName];
|
||||
if (!path) return null;
|
||||
const base = endpointUrl.replace(/\/$/, "");
|
||||
const q = new URLSearchParams();
|
||||
// Always pin the requested project. Without this the server would silently
|
||||
// answer from whatever project its active editor points at.
|
||||
if (projectDir) q.set("project", projectDir);
|
||||
switch (toolName) {
|
||||
case "get_status":
|
||||
case "list_projects":
|
||||
break;
|
||||
case "find_asset":
|
||||
case "find_references":
|
||||
case "get_asset_references":
|
||||
q.set("id", String(args.id ?? ""));
|
||||
if (args.type != null) q.set("type", String(args.type));
|
||||
if (toolName === "get_asset_references") {
|
||||
if (args.depth != null) q.set("depth", String(args.depth));
|
||||
if (Array.isArray(args.targetTypes)) {
|
||||
q.set("targetTypes", (args.targetTypes as unknown[]).map(String).join(","));
|
||||
}
|
||||
if (args.maxEdges != null) q.set("maxEdges", String(args.maxEdges));
|
||||
if (args.includeUnresolved != null) {
|
||||
q.set("includeUnresolved", String(args.includeUnresolved));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "list_assets_by_type":
|
||||
q.set("type", String(args.type ?? ""));
|
||||
if (args.prefix != null) q.set("prefix", String(args.prefix));
|
||||
if (args.limit != null) q.set("limit", String(args.limit));
|
||||
break;
|
||||
case "is_file_active":
|
||||
q.set("path", String(args.path ?? ""));
|
||||
break;
|
||||
case "find_define":
|
||||
q.set("name", String(args.name ?? ""));
|
||||
break;
|
||||
case "resolve_include":
|
||||
q.set("source", String(args.source ?? ""));
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
const query = q.toString();
|
||||
return query ? `${base}${path}?${query}` : `${base}${path}`;
|
||||
}
|
||||
|
||||
export interface LiveClientOptions {
|
||||
/** Project the client was started for (pins every request). */
|
||||
projectDir?: string | null;
|
||||
/** Override the agent home directory (used by tests). */
|
||||
agentHome?: string;
|
||||
/** Clock injection for deterministic negative-cache tests. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a usable endpoint for this client's project.
|
||||
*
|
||||
* Order: per-project file, then any live instance whose `projects` list
|
||||
* contains this project, then the legacy global pointer (only when its
|
||||
* recorded project matches). Endpoints with a dead PID are skipped.
|
||||
*/
|
||||
export async function findEndpoint(
|
||||
options: LiveClientOptions = {},
|
||||
): Promise<AgentEndpoint | null> {
|
||||
const projectDir = options.projectDir ?? null;
|
||||
const agentHome = options.agentHome;
|
||||
|
||||
if (projectDir) {
|
||||
const direct = await readEndpointForProject(projectDir, agentHome);
|
||||
if (direct && isProcessAlive(direct.processId)) return direct;
|
||||
}
|
||||
|
||||
// Scan live instances: this is what makes a freshly started window work
|
||||
// even if its per-project file has not been written yet.
|
||||
try {
|
||||
const instances = await readInstances(agentHome);
|
||||
for (const instance of instances) {
|
||||
if (!isProcessAlive(instance.processId)) continue;
|
||||
if (projectDir && instance.projectDir && !sameProject(instance.projectDir, projectDir)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
projectDir &&
|
||||
instance.projects?.length &&
|
||||
!instance.projects.some((p) => sameProject(p, projectDir))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
} catch {
|
||||
// Instance scanning is best-effort.
|
||||
}
|
||||
|
||||
const fallback = await readEndpoint(agentHome);
|
||||
if (fallback && isProcessAlive(fallback.processId)) {
|
||||
if (
|
||||
!projectDir ||
|
||||
!fallback.projectDir ||
|
||||
sameProject(fallback.projectDir, projectDir)
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the live extension server. Returns null when no live index is
|
||||
* reachable, so callers can fall back to the on-disk snapshot.
|
||||
*/
|
||||
export class LiveClient {
|
||||
private unavailableUntil = 0;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(private readonly options: LiveClientOptions = {}) {
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
/** True while the negative cache is suppressing attempts. */
|
||||
get suppressed(): boolean {
|
||||
return this.now() < this.unavailableUntil;
|
||||
}
|
||||
|
||||
/** Clears the negative cache (e.g. after the user re-enables access). */
|
||||
reset(): void {
|
||||
this.unavailableUntil = 0;
|
||||
}
|
||||
|
||||
/** Marks the live path unavailable for the cooldown period. */
|
||||
markUnavailable(): void {
|
||||
this.unavailableUntil = this.now() + LIVE_RETRY_COOLDOWN_MS;
|
||||
}
|
||||
|
||||
async query(
|
||||
toolName: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<LiveQueryResult | null> {
|
||||
if (this.suppressed) return null;
|
||||
if (typeof fetch !== "function") return null;
|
||||
const projectDir = this.options.projectDir ?? null;
|
||||
const endpoint = await findEndpoint(this.options);
|
||||
if (!endpoint) return null;
|
||||
|
||||
const url = liveUrlForTool(endpoint.url, projectDir, toolName, args);
|
||||
if (!url) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), LIVE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${endpoint.token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// 401/404 mean the endpoint is stale rather than merely slow.
|
||||
if (res.status === 401 || res.status === 404) this.markUnavailable();
|
||||
return null;
|
||||
}
|
||||
const payload: unknown = await res.json();
|
||||
return {
|
||||
payload,
|
||||
mismatched: responseProjectMismatch(payload, projectDir),
|
||||
endpoint,
|
||||
};
|
||||
} catch {
|
||||
this.markUnavailable();
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience one-shot query using a fresh client. */
|
||||
export async function queryLive(
|
||||
toolName: string,
|
||||
args: Record<string, unknown> = {},
|
||||
options: LiveClientOptions = {},
|
||||
): Promise<LiveQueryResult | null> {
|
||||
return new LiveClient(options).query(toolName, args);
|
||||
}
|
||||
+28
-148
@@ -17,11 +17,8 @@
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
isProcessAlive,
|
||||
readEndpoint,
|
||||
readEndpointForProject,
|
||||
} from "./endpoint";
|
||||
import { LiveClient } from "./liveClient";
|
||||
import { pruneInstances } from "./instances";
|
||||
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
|
||||
import {
|
||||
findAssets,
|
||||
@@ -53,6 +50,7 @@ Use these tools instead of full-text grepping the XML tree when you need exact f
|
||||
- is_file_active(path) -> whether a file is part of an indexed include stream
|
||||
- find_define(name) -> $DEFINE definitions
|
||||
- resolve_include(source) -> candidate source file
|
||||
- list_projects() -> project roots the live extension has indexed
|
||||
- get_status() -> current index state
|
||||
|
||||
Tips:
|
||||
@@ -228,6 +226,17 @@ const TOOLS: McpTool[] = [
|
||||
"get_asset_references requires a live index. Open the project in VS Code (with AI Agent access enabled) and retry.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "list_projects",
|
||||
description:
|
||||
"Lists the project roots the live extension currently has indexed. Use it to discover which projects this server can answer for.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
handler: () => ({
|
||||
index: { state: "no_index" },
|
||||
error:
|
||||
"list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "get_usage_guide",
|
||||
description: "Returns guidance for using the RA3 Mod XML index tools.",
|
||||
@@ -237,142 +246,7 @@ const TOOLS: McpTool[] = [
|
||||
];
|
||||
|
||||
/** Tools that can only be answered by the live extension server. */
|
||||
const LIVE_ONLY_TOOLS = new Set(["get_asset_references"]);
|
||||
|
||||
export function liveUrlForTool(
|
||||
endpointUrl: string,
|
||||
projectDir: string | null,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): string | null {
|
||||
const base = endpointUrl.replace(/\/$/, "");
|
||||
const q = new URLSearchParams();
|
||||
// Always pin the requested project. Without this the server would silently
|
||||
// answer from whatever project its active editor points at.
|
||||
if (projectDir) q.set("project", projectDir);
|
||||
switch (toolName) { case "get_status":
|
||||
return q.toString() ? `${base}/status?${q}` : `${base}/status`;
|
||||
case "find_asset":
|
||||
q.set("id", String(args.id ?? ""));
|
||||
if (args.type != null) q.set("type", String(args.type));
|
||||
return `${base}/find_asset?${q}`;
|
||||
case "find_references":
|
||||
q.set("id", String(args.id ?? ""));
|
||||
if (args.type != null) q.set("type", String(args.type));
|
||||
return `${base}/find_references?${q}`;
|
||||
case "get_asset_references":
|
||||
q.set("id", String(args.id ?? ""));
|
||||
if (args.type != null) q.set("type", String(args.type));
|
||||
if (args.depth != null) q.set("depth", String(args.depth));
|
||||
if (Array.isArray(args.targetTypes)) {
|
||||
q.set("targetTypes", (args.targetTypes as unknown[]).map(String).join(","));
|
||||
}
|
||||
if (args.maxEdges != null) q.set("maxEdges", String(args.maxEdges));
|
||||
if (args.includeUnresolved != null) {
|
||||
q.set("includeUnresolved", String(args.includeUnresolved));
|
||||
}
|
||||
return `${base}/get_asset_references?${q}`;
|
||||
case "list_assets_by_type":
|
||||
q.set("type", String(args.type ?? ""));
|
||||
if (args.prefix != null) q.set("prefix", String(args.prefix));
|
||||
if (args.limit != null) q.set("limit", String(args.limit));
|
||||
return `${base}/list_assets?${q}`;
|
||||
case "is_file_active":
|
||||
q.set("path", String(args.path ?? ""));
|
||||
return `${base}/is_file_active?${q}`;
|
||||
case "find_define":
|
||||
q.set("name", String(args.name ?? ""));
|
||||
return `${base}/find_define?${q}`;
|
||||
case "resolve_include":
|
||||
q.set("source", String(args.source ?? ""));
|
||||
return `${base}/resolve_include?${q}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a live response that belongs to a different project than the one
|
||||
* this MCP client was started for. This is the guard against the
|
||||
* multi-window cross-talk described in docs/ai-agent-integration-plan.md §10:
|
||||
* returning another project's data silently would be worse than returning
|
||||
* nothing, because the agent cannot tell the difference.
|
||||
*/
|
||||
export function responseProjectMismatch(
|
||||
payload: unknown,
|
||||
projectDir: string | null,
|
||||
): boolean {
|
||||
if (!projectDir) return false;
|
||||
const index = (payload as { index?: { projectDir?: string } } | null)?.index;
|
||||
const reported = index?.projectDir;
|
||||
if (!reported) return false;
|
||||
return normalizePath(reported) !== normalizePath(projectDir);
|
||||
}
|
||||
|
||||
/** Cooldown after a failed live attempt, to avoid a probe per tool call. */
|
||||
const LIVE_RETRY_COOLDOWN_MS = 5000;
|
||||
let liveUnavailableUntil = 0;
|
||||
|
||||
interface LiveResult {
|
||||
payload: unknown;
|
||||
/** True when the live server answered but for a different project. */
|
||||
mismatched: boolean;
|
||||
}
|
||||
|
||||
/** Tries the live extension server; returns null when unavailable. */
|
||||
async function tryLiveQuery(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
projectDir: string | null,
|
||||
agentHome?: string,
|
||||
): Promise<LiveResult | null> {
|
||||
if (Date.now() < liveUnavailableUntil) return null;
|
||||
// Prefer the per-project endpoint so two open windows cannot shadow each
|
||||
// other; fall back to the legacy global file only when it matches.
|
||||
let endpoint = projectDir
|
||||
? await readEndpointForProject(projectDir, agentHome)
|
||||
: null;
|
||||
if (!endpoint) {
|
||||
const fallback = await readEndpoint(agentHome);
|
||||
if (
|
||||
fallback &&
|
||||
(!projectDir ||
|
||||
!fallback.projectDir ||
|
||||
normalizePath(fallback.projectDir) === normalizePath(projectDir))
|
||||
) {
|
||||
endpoint = fallback;
|
||||
}
|
||||
}
|
||||
if (!endpoint) return null;
|
||||
// A crashed VS Code can leave the file behind; a dead PID means stale.
|
||||
if (!isProcessAlive(endpoint.processId)) return null;
|
||||
|
||||
const url = liveUrlForTool(endpoint.url, projectDir, toolName, args);
|
||||
if (!url) return null;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1500);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${endpoint.token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 404) return null;
|
||||
return null;
|
||||
}
|
||||
const payload: unknown = await res.json();
|
||||
return { payload, mismatched: responseProjectMismatch(payload, projectDir) };
|
||||
} catch {
|
||||
liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
const LIVE_ONLY_TOOLS = new Set(["get_asset_references", "list_projects"]);
|
||||
|
||||
function sendMessage(message: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
@@ -390,7 +264,7 @@ async function handleRequest(
|
||||
message: Record<string, unknown>,
|
||||
snapshot: AgentIndexSnapshot | null,
|
||||
projectDir: string | null,
|
||||
agentHome?: string,
|
||||
live: LiveClient,
|
||||
): Promise<unknown | null> {
|
||||
const method = String(message.method ?? "");
|
||||
const id = message.id;
|
||||
@@ -418,12 +292,12 @@ async function handleRequest(
|
||||
const tool = TOOLS.find((t) => t.name === toolName);
|
||||
if (!tool) return errorFor(id, -32602, `Unknown tool: ${toolName}`);
|
||||
const args = (params.arguments ?? {}) as Record<string, unknown>;
|
||||
const live = await tryLiveQuery(toolName, args, projectDir, agentHome);
|
||||
const result = await live.query(toolName, args);
|
||||
|
||||
if (live?.mismatched) {
|
||||
if (result?.mismatched) {
|
||||
// The server answered for another project. Refuse it: a plausible
|
||||
// wrong answer is worse than an explicit failure.
|
||||
liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS;
|
||||
live.markUnavailable();
|
||||
return textResult(id, {
|
||||
index: { state: "error", projectDir: projectDir ?? undefined },
|
||||
error: `The live server answered for a different project than "${projectDir}"; refusing the result. Re-run "RA3 Mod XML: Enable AI Agent access…" for this project.`,
|
||||
@@ -435,14 +309,14 @@ async function handleRequest(
|
||||
// not conclude "this asset has no references".
|
||||
return textResult(
|
||||
id,
|
||||
live?.payload ?? {
|
||||
result?.payload ?? {
|
||||
index: { state: "no_index", projectDir: projectDir ?? undefined },
|
||||
error: `"${toolName}" requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const output = live?.payload ?? tool.handler(args, snapshot);
|
||||
const output = result?.payload ?? tool.handler(args, snapshot);
|
||||
return textResult(id, output);
|
||||
}
|
||||
default:
|
||||
@@ -474,6 +348,12 @@ async function main(): Promise<void> {
|
||||
let snapshot: AgentIndexSnapshot | null = null;
|
||||
if (resolvedSnapshotPath) snapshot = await readSnapshotFile(resolvedSnapshotPath);
|
||||
|
||||
// One-shot crash cleanup: a window that died without disposing leaves its
|
||||
// instance file behind, and whichever instance starts next prunes it.
|
||||
void pruneInstances(agentHome).catch(() => undefined);
|
||||
|
||||
const live = new LiveClient({ projectDir, agentHome });
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
@@ -486,7 +366,7 @@ async function main(): Promise<void> {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void handleRequest(message, snapshot, projectDir, agentHome).then((response) => {
|
||||
void handleRequest(message, snapshot, projectDir, live).then((response) => {
|
||||
if (response != null) sendMessage(response);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Runtime resolution for the agent launcher and CLI.
|
||||
*
|
||||
* The extension must not assume the user has Node installed. VS Code ships an
|
||||
* Electron binary that can run as a plain Node process when launched with
|
||||
* `ELECTRON_RUN_AS_NODE=1`, which makes it a zero-dependency runtime that is
|
||||
* already present wherever the extension is installed.
|
||||
*
|
||||
* Verified on Windows with VS Code 1.135.0 / Electron 42.8.1 (Node 24.18.1):
|
||||
* `fs`, `fs/promises`, `path`, `os`, `http`, `readline`, `zlib`, `crypto`,
|
||||
* `util`, `net`, `child_process` and even `node:test` are all available, and
|
||||
* the bundled `dist/agent/mcpServer.js` completes a full stdio MCP session.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency, so the same rules are used by the
|
||||
* launcher generator, the CLI and the tests.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
export type AgentRuntimeKind = "electron" | "node";
|
||||
|
||||
export interface AgentRuntime {
|
||||
kind: AgentRuntimeKind;
|
||||
/**
|
||||
* Executable to spawn. For `electron` this is the VS Code / Electron binary
|
||||
* (or a bare product name when only a hint is known).
|
||||
*/
|
||||
executable: string;
|
||||
/** Extra environment variables required to run the executable as Node. */
|
||||
env: Record<string, string>;
|
||||
/**
|
||||
* True when the executable is a bare command name resolved through PATH
|
||||
* rather than an absolute path.
|
||||
*/
|
||||
viaPath: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the current process is a VS Code / Electron host.
|
||||
*
|
||||
* Desktop VS Code runs its extension host as the Electron binary with
|
||||
* `ELECTRON_RUN_AS_NODE=1`, so `process.versions.electron` is set and
|
||||
* `process.execPath` points at the VS Code executable. That is exactly the
|
||||
* binary the launcher wants.
|
||||
*/
|
||||
export function isElectronHost(): boolean {
|
||||
return typeof process.versions.electron === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the runtime to bake into the launcher.
|
||||
*
|
||||
* Returns the Electron runtime when running inside VS Code (the normal case),
|
||||
* and falls back to a PATH-resolved `node` otherwise (e.g. when the launcher
|
||||
* is generated from a plain Node CLI or a unit test).
|
||||
*/
|
||||
export function resolveRuntime(): AgentRuntime {
|
||||
if (isElectronHost()) {
|
||||
return {
|
||||
kind: "electron",
|
||||
executable: process.execPath,
|
||||
env: { ELECTRON_RUN_AS_NODE: "1" },
|
||||
viaPath: false,
|
||||
};
|
||||
}
|
||||
return { kind: "node", executable: "node", env: {}, viaPath: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the runtime descriptor for an explicit Electron/VSCode executable.
|
||||
* Used when the extension knows the host path but is not itself running as
|
||||
* Electron, and by tests.
|
||||
*/
|
||||
export function electronRuntime(executable: string): AgentRuntime {
|
||||
return {
|
||||
kind: "electron",
|
||||
executable,
|
||||
env: { ELECTRON_RUN_AS_NODE: "1" },
|
||||
viaPath: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Product names that indicate a VS Code-family Electron binary.
|
||||
*
|
||||
* Covers both the branded Windows/Linux launchers (`Code.exe`, `code-oss`,
|
||||
* `codium`, `cursor`) and the macOS bundle executable, which is literally
|
||||
* named `Electron`.
|
||||
*/
|
||||
const ELECTRON_PRODUCT_NAMES = [
|
||||
"code",
|
||||
"code-insiders",
|
||||
"codium",
|
||||
"cursor",
|
||||
"electron",
|
||||
];
|
||||
|
||||
/**
|
||||
* Best-effort candidates for a VS Code / Electron binary when running under a
|
||||
* plain Node process. These are only fallbacks: the authoritative path always
|
||||
* comes from `process.execPath` inside the extension host.
|
||||
*/
|
||||
export function electronExecutableCandidates(): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (isWin && process.env.LOCALAPPDATA) {
|
||||
candidates.push(
|
||||
`${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code\\Code.exe`,
|
||||
`${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code Insiders\\Code - Insiders.exe`,
|
||||
);
|
||||
}
|
||||
if (isWin && process.env.PROGRAMFILES) {
|
||||
candidates.push(`${process.env.PROGRAMFILES}\\Microsoft VS Code\\Code.exe`);
|
||||
}
|
||||
if (isMac) {
|
||||
candidates.push("/Applications/Visual Studio Code.app/Contents/MacOS/Electron");
|
||||
} else if (!isWin) {
|
||||
candidates.push("/usr/share/code/code", "/usr/bin/code");
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/** The first existing Electron candidate, or null. */
|
||||
export function findElectronExecutable(): string | null {
|
||||
for (const candidate of electronExecutableCandidates()) {
|
||||
try {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Renders a path for a POSIX shell single-quoted string. */
|
||||
function shQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/** Renders a path for a Windows `cmd.exe` double-quoted `set` value. */
|
||||
function cmdValue(value: string): string {
|
||||
// `set "VAR=value"` keeps quotes out of the value; embedded `"` would break
|
||||
// it, so strip them rather than emit a broken script.
|
||||
return value.replace(/"/g, "");
|
||||
}
|
||||
|
||||
export interface LauncherScriptOptions {
|
||||
runtime: AgentRuntime;
|
||||
/** Absolute path to the bundled MCP server entry. */
|
||||
serverPath: string;
|
||||
/** Project root passed to the MCP server. */
|
||||
projectDir: string;
|
||||
/**
|
||||
* Optional absolute path to a `node` executable used when the Electron
|
||||
* binary is missing. When omitted the script falls back to `node` on PATH.
|
||||
*/
|
||||
nodeFallback?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the stable launcher script.
|
||||
*
|
||||
* The script prefers the Electron runtime (works without Node installed) and
|
||||
* falls back to Node only when that binary is gone, so a moved/uninstalled
|
||||
* VS Code does not leave the user with a dead launcher.
|
||||
*/
|
||||
export function launcherScript(
|
||||
options: LauncherScriptOptions,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
const { runtime, serverPath, projectDir } = options;
|
||||
const node = options.nodeFallback ?? "node";
|
||||
const paths = { serverPath: cmdValue(serverPath), projectDir: cmdValue(projectDir) };
|
||||
|
||||
if (platform === "win32") {
|
||||
// A `goto` jump is used instead of a parenthesised `if (...)` block:
|
||||
// inside such a block `%errorlevel%` is expanded when the whole block is
|
||||
// parsed, not when each command runs, so the exit code would be wrong.
|
||||
const lines = ["@echo off", "setlocal"];
|
||||
if (runtime.kind === "node") {
|
||||
// No Electron available: the launcher is Node-only.
|
||||
lines.push(
|
||||
`set "RA3_NODE=${cmdValue(node)}"`,
|
||||
`set "RA3_SERVER=${paths.serverPath}"`,
|
||||
`set "RA3_PROJECT=${paths.projectDir}"`,
|
||||
'"%RA3_NODE%" "%RA3_SERVER%" --project "%RA3_PROJECT%"',
|
||||
"exit /b %errorlevel%",
|
||||
);
|
||||
return lines.join("\r\n") + "\r\n";
|
||||
}
|
||||
lines.push(
|
||||
`set "RA3_RUNTIME=${cmdValue(runtime.executable)}"`,
|
||||
`set "RA3_SERVER=${paths.serverPath}"`,
|
||||
`set "RA3_PROJECT=${paths.projectDir}"`,
|
||||
`set "RA3_NODE=${cmdValue(node)}"`,
|
||||
'if not exist "%RA3_RUNTIME%" goto :ra3_node',
|
||||
"set ELECTRON_RUN_AS_NODE=1",
|
||||
'"%RA3_RUNTIME%" "%RA3_SERVER%" --project "%RA3_PROJECT%"',
|
||||
"exit /b %errorlevel%",
|
||||
":ra3_node",
|
||||
'"%RA3_NODE%" "%RA3_SERVER%" --project "%RA3_PROJECT%"',
|
||||
"exit /b %errorlevel%",
|
||||
);
|
||||
return lines.join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
const lines = ["#!/usr/bin/env sh"];
|
||||
if (runtime.kind === "node") {
|
||||
lines.push(
|
||||
`exec ${shQuote(node)} ${shQuote(serverPath)} --project ${shQuote(projectDir)}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`RA3_RUNTIME=${shQuote(runtime.executable)}`,
|
||||
`RA3_SERVER=${shQuote(serverPath)}`,
|
||||
`RA3_PROJECT=${shQuote(projectDir)}`,
|
||||
'if [ -x "$RA3_RUNTIME" ]; then',
|
||||
' ELECTRON_RUN_AS_NODE=1 exec "$RA3_RUNTIME" "$RA3_SERVER" --project "$RA3_PROJECT"',
|
||||
"fi",
|
||||
`exec ${shQuote(node)} "$RA3_SERVER" --project "$RA3_PROJECT"`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the launcher is able to run without a Node installation: the
|
||||
* Electron runtime is used unconditionally (no PATH lookup of `node`).
|
||||
*
|
||||
* Used by tests and by the enable flow to warn when the launcher would depend
|
||||
* on Node being installed.
|
||||
*/
|
||||
export function isNodeFreeLauncher(script: string, platform: NodeJS.Platform = process.platform): boolean {
|
||||
if (platform === "win32") {
|
||||
return (
|
||||
script.includes("ELECTRON_RUN_AS_NODE=1") &&
|
||||
script.includes('set "RA3_RUNTIME=') &&
|
||||
// The Node path must only be reachable through the guard jump.
|
||||
script.includes('if not exist "%RA3_RUNTIME%" goto :ra3_node') &&
|
||||
!/^\s*"node"\s/m.test(script)
|
||||
);
|
||||
}
|
||||
return (
|
||||
script.includes("ELECTRON_RUN_AS_NODE=1") &&
|
||||
script.includes("RA3_RUNTIME=") &&
|
||||
!/^exec node /m.test(script)
|
||||
);
|
||||
}
|
||||
|
||||
/** Product names that indicate a VS Code-family Electron binary. */
|
||||
export function looksLikeElectronExecutable(path: string): boolean {
|
||||
const base = path.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
|
||||
return ELECTRON_PRODUCT_NAMES.some((name) => base.includes(name));
|
||||
}
|
||||
+39
-18
@@ -13,6 +13,11 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { defaultAgentHome } from "./snapshot";
|
||||
import {
|
||||
launcherScript,
|
||||
resolveRuntime,
|
||||
type AgentRuntime,
|
||||
} from "./runtime";
|
||||
|
||||
export interface McpConfigTarget {
|
||||
id: string;
|
||||
@@ -20,6 +25,18 @@ export interface McpConfigTarget {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Result of writing the stable launcher. */
|
||||
export interface LauncherResult {
|
||||
path: string;
|
||||
serverPath: string;
|
||||
runtime: AgentRuntime;
|
||||
/**
|
||||
* True when the launcher runs on the VS Code Electron runtime and therefore
|
||||
* does not need a Node installation.
|
||||
*/
|
||||
nodeFree: boolean;
|
||||
}
|
||||
|
||||
/** File name of the stable launcher on the current platform. */
|
||||
export function launcherFileName(): string {
|
||||
return process.platform === "win32" ? "ra3-mod-xml-mcp.cmd" : "ra3-mod-xml-mcp";
|
||||
@@ -39,34 +56,38 @@ export function bundledMcpServerPath(extensionRoot: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the stable launcher script. It points to the current extension's
|
||||
* bundled MCP server and passes the project directory.
|
||||
* Creates the stable launcher script. It prefers the VS Code Electron runtime
|
||||
* (so no Node installation is required) and falls back to Node only when that
|
||||
* binary is missing.
|
||||
*
|
||||
* Returns the launcher path plus the resolved runtime, so callers can warn
|
||||
* when the launcher will depend on Node being on PATH.
|
||||
*/
|
||||
export async function writeLauncher(
|
||||
extensionRoot: string,
|
||||
projectDir: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<string> {
|
||||
runtime?: AgentRuntime,
|
||||
): Promise<LauncherResult> {
|
||||
const server = bundledMcpServerPath(extensionRoot);
|
||||
const launcher = launcherPath(agentHome);
|
||||
const resolved = runtime ?? resolveRuntime();
|
||||
const script = launcherScript({
|
||||
runtime: resolved,
|
||||
serverPath: server,
|
||||
projectDir,
|
||||
});
|
||||
await mkdir(dirname(launcher), { recursive: true });
|
||||
if (process.platform === "win32") {
|
||||
const content = [
|
||||
"@echo off",
|
||||
`node "${server}" --project "${projectDir}"`,
|
||||
"",
|
||||
].join("\r\n");
|
||||
await writeFile(launcher, content, "utf8");
|
||||
} else {
|
||||
const content = [
|
||||
"#!/usr/bin/env sh",
|
||||
`exec node "${server}" --project "${projectDir}"`,
|
||||
"",
|
||||
].join("\n");
|
||||
await writeFile(launcher, content, "utf8");
|
||||
await writeFile(launcher, script, "utf8");
|
||||
if (process.platform !== "win32") {
|
||||
await chmod(launcher, 0o755);
|
||||
}
|
||||
return launcher;
|
||||
return {
|
||||
path: launcher,
|
||||
serverPath: server,
|
||||
runtime: resolved,
|
||||
nodeFree: resolved.kind === "electron",
|
||||
};
|
||||
}
|
||||
|
||||
/** MCP client config entry for one project. */
|
||||
|
||||
+41
-2
@@ -73,6 +73,46 @@ Use this skill when you need any of the following:
|
||||
Do not use full-text search over the XML tree when one of the MCP query tools
|
||||
can answer the question directly.
|
||||
|
||||
## Reaching the index
|
||||
|
||||
Work down this list and stop at the first step that works.
|
||||
|
||||
1. **The query tools are already in your tool list** (names like
|
||||
\`find_asset\`, \`get_status\`). Use them directly. You do not need to
|
||||
configure anything.
|
||||
|
||||
2. **The tools are not available, but you can run commands.** The index is
|
||||
reachable without any MCP setup, because the MCP server speaks JSON-RPC over
|
||||
stdio. Read \`~/.ra3modxml/index.json\` first: it is a small, stable
|
||||
discovery manifest listing the live instances and their project roots.
|
||||
|
||||
- If it does not exist, the extension has never been enabled for this
|
||||
project. Tell the user to open the project in VS Code and run
|
||||
"RA3 Mod XML: Enable AI Agent access…". Do not guess or search further.
|
||||
- If it exists, use the launcher at \`~/.ra3modxml/ra3-mod-xml-mcp.cmd\`
|
||||
(Windows) or \`~/.ra3modxml/ra3-mod-xml-mcp\` (elsewhere), or call the
|
||||
bundled server directly through that instance's runtime. Feed it one
|
||||
JSON-RPC request per line on stdin, for example:
|
||||
|
||||
\`\`\`
|
||||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_asset","arguments":{"id":"AthenaCannon","type":"GameObject"}}}
|
||||
\`\`\`
|
||||
|
||||
A CLI with the same capabilities is bundled alongside the MCP server
|
||||
(\`cli.js\` next to \`mcpServer.js\` in the extension's \`dist/agent/\`).
|
||||
Run it with the same runtime as the launcher; \`cli.js help\` lists the
|
||||
commands. It answers from the live index when VS Code is running and
|
||||
falls back to the last exported snapshot otherwise. Commands that need
|
||||
element context (\`outgoing\`, \`projects\`) require the live index and will
|
||||
say so explicitly instead of returning an empty result.
|
||||
|
||||
3. **You can write configuration, if the user agrees.** You may add the MCP
|
||||
server to the your own harness' configuration.
|
||||
Check what your own client supports (e.g. if a restart is required)
|
||||
before promising otherwise.
|
||||
|
||||
If none of the steps work, say the index is unavailable and read the XML files directly.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Call \`get_status\` first when you are unsure whether an index is available,
|
||||
@@ -83,8 +123,7 @@ can answer the question directly.
|
||||
- \`get_asset_references(id, type?, depth?, targetTypes?)\` for outgoing
|
||||
references (what this asset uses, and where that link is written).
|
||||
- \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets.
|
||||
- \`is_file_active(path)\` to determine whether a file is included in an indexed stream.
|
||||
- \`find_define(name)\` for $DEFINE constants.
|
||||
- \`is_file_active(path)\` to determine whether a file is included in an indexed stream. - \`find_define(name)\` for $DEFINE constants.
|
||||
- \`resolve_include(source)\` for Include source candidates.
|
||||
3. Asset ids are case-insensitive.
|
||||
4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions.
|
||||
|
||||
+112
-10
@@ -54,6 +54,13 @@ import {
|
||||
writeEndpoint,
|
||||
writeEndpointForProject,
|
||||
} from "./agent/endpoint";
|
||||
import {
|
||||
clearInstance,
|
||||
makeInstanceId,
|
||||
pruneInstances,
|
||||
writeInstance,
|
||||
writeManifest,
|
||||
} from "./agent/instances";
|
||||
|
||||
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
|
||||
/** Safety-net refresh interval while a rebuild is running. */
|
||||
@@ -159,9 +166,25 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
let agentLocalServer: LocalServerHandle | null = null;
|
||||
/** Project roots whose per-project endpoint file this window wrote. */
|
||||
const agentEndpointProjects = new Set<string>();
|
||||
/**
|
||||
* This window's own instance id. Each window writes only its own file under
|
||||
* `instances/`, which is what removes the need for locking/merging between
|
||||
* concurrently running VS Code windows.
|
||||
*/
|
||||
const agentInstanceId = makeInstanceId();
|
||||
const startAgentLocalServer = async (): Promise<void> => {
|
||||
if (agentLocalServer) return;
|
||||
try {
|
||||
// Clean up instances left behind by crashed windows. Any instance can do
|
||||
// this, so a crash does not have to wait for the same workspace to be
|
||||
// reopened before its stale entry disappears.
|
||||
const pruned = await pruneInstances().catch(() => ({ removed: [], kept: [] }));
|
||||
if (pruned.removed.length) {
|
||||
ws.log(
|
||||
`[agent] pruned ${pruned.removed.length} stale instance(s): ${pruned.removed.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const handle = await startLocalServer({
|
||||
// Route by explicit project so a query can never be answered by
|
||||
// whichever project the active editor happens to point at.
|
||||
@@ -177,6 +200,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
const url = `http://127.0.0.1:${handle.port}`;
|
||||
const projects = ws.getProjectRoots();
|
||||
const endpoint = {
|
||||
instanceId: agentInstanceId,
|
||||
url,
|
||||
token: handle.token,
|
||||
projectDir: ws.projectRoot ?? undefined,
|
||||
@@ -193,8 +217,12 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
agentEndpointProjects.add(project);
|
||||
ws.log(`[agent-local-server] endpoint for ${project} -> ${file}`);
|
||||
}
|
||||
// This window's own instance file (authoritative for liveness/pruning).
|
||||
const instanceFile = await writeInstance(endpoint);
|
||||
ws.log(`[agent-local-server] instance -> ${instanceFile}`);
|
||||
// Legacy/global pointer for tooling that does not know the project.
|
||||
await writeEndpoint(endpoint);
|
||||
await writeManifest(await refreshInstanceFiles(url, handle.token, projects));
|
||||
ws.log(`[agent-local-server] listening on ${url}`);
|
||||
} catch (err) {
|
||||
ws.log(
|
||||
@@ -202,24 +230,54 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Re-reads every live instance file (including other windows') and rewrites
|
||||
* the merged discovery manifest.
|
||||
*/
|
||||
const refreshInstanceFiles = async (
|
||||
url: string,
|
||||
token: string,
|
||||
projects: string[],
|
||||
) => {
|
||||
const instance = {
|
||||
instanceId: agentInstanceId,
|
||||
url,
|
||||
token,
|
||||
projectDir: ws.projectRoot ?? undefined,
|
||||
projects,
|
||||
processId: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeInstance(instance).catch(() => undefined);
|
||||
const { kept } = await pruneInstances().catch(() => ({
|
||||
removed: [],
|
||||
kept: [] as typeof instance[],
|
||||
}));
|
||||
// Ensure this window is present even if its file was just pruned/written.
|
||||
const others = kept.filter((k) => k.instanceId !== agentInstanceId);
|
||||
return [...others, instance];
|
||||
};
|
||||
const stopAgentLocalServer = async (): Promise<void> => {
|
||||
if (agentLocalServer) {
|
||||
const server = agentLocalServer;
|
||||
agentLocalServer = null;
|
||||
await server.close().catch(() => undefined);
|
||||
}
|
||||
// Only remove the endpoint files this window wrote: another VS Code
|
||||
// window may still be serving its own projects.
|
||||
// Only remove what this window owns: another VS Code window may still be
|
||||
// serving its own projects.
|
||||
for (const project of agentEndpointProjects) {
|
||||
await clearEndpointForProject(project).catch(() => undefined);
|
||||
}
|
||||
agentEndpointProjects.clear();
|
||||
await clearInstance(agentInstanceId).catch(() => undefined);
|
||||
await clearEndpoint().catch(() => undefined);
|
||||
await writeManifest([]).catch(() => undefined);
|
||||
};
|
||||
/**
|
||||
* Publishes per-project endpoint files for every project this window now
|
||||
* Republishes this window's endpoint/instance files for every project it now
|
||||
* knows about. Called on each index update so projects discovered later get
|
||||
* an endpoint without restarting the server.
|
||||
* an endpoint without restarting the server, and so the merged manifest is
|
||||
* refreshed.
|
||||
*/
|
||||
const refreshAgentEndpoints = async (): Promise<void> => {
|
||||
if (!agentLocalServer) return;
|
||||
@@ -243,6 +301,8 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
);
|
||||
}
|
||||
}
|
||||
const instances = await refreshInstanceFiles(url, token, projects);
|
||||
await writeManifest(instances);
|
||||
};
|
||||
context.subscriptions.push({
|
||||
dispose: () => {
|
||||
@@ -487,7 +547,17 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
context.extensionUri.fsPath,
|
||||
projectDir,
|
||||
);
|
||||
const configJson = mcpConfigJson(launcher, projectDir);
|
||||
const configJson = mcpConfigJson(launcher.path, projectDir);
|
||||
ws.log(
|
||||
`[agent] launcher runtime=${launcher.runtime.kind} (${launcher.runtime.executable}), nodeFree=${launcher.nodeFree}`,
|
||||
);
|
||||
if (!launcher.nodeFree) {
|
||||
void vscode.window.showWarningMessage(
|
||||
t(
|
||||
"RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.",
|
||||
),
|
||||
);
|
||||
}
|
||||
agentAccessEnabled = true;
|
||||
await context.workspaceState.update("ra3modxml.agentAccessEnabled", true);
|
||||
void startAgentLocalServer();
|
||||
@@ -538,17 +608,17 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
t("RA3 Mod XML Agent Skill installed to {0}", target),
|
||||
);
|
||||
} else if (pick?.id === "claude") {
|
||||
await addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher, projectDir);
|
||||
await addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher.path, projectDir);
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()),
|
||||
);
|
||||
} else if (pick?.id === "cursor-global") {
|
||||
await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher, projectDir);
|
||||
await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher.path, projectDir);
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()),
|
||||
);
|
||||
} else if (pick?.id === "cursor-project") {
|
||||
await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher, projectDir);
|
||||
await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher.path, projectDir);
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)),
|
||||
);
|
||||
@@ -638,11 +708,43 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
),
|
||||
);
|
||||
|
||||
if (agentAccessEnabled) void startAgentLocalServer();
|
||||
/**
|
||||
* Refreshes the stable launcher for every known project. Called on
|
||||
* activation for already-enabled workspaces so a VS Code update (or an
|
||||
* extension update) repoints the launcher at the current runtime without
|
||||
* the user having to re-run the enable command.
|
||||
*/
|
||||
const refreshLaunchers = async (): Promise<void> => {
|
||||
const projects = ws.getProjectRoots();
|
||||
if (!projects.length) return;
|
||||
for (const project of projects) {
|
||||
try {
|
||||
const launcher = await writeLauncher(
|
||||
context.extensionUri.fsPath,
|
||||
project,
|
||||
);
|
||||
ws.log(
|
||||
`[agent] refreshed launcher for ${project}: runtime=${launcher.runtime.kind}, nodeFree=${launcher.nodeFree}`,
|
||||
);
|
||||
} catch (err) {
|
||||
ws.log(
|
||||
`[agent] could not refresh launcher for ${project}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (agentAccessEnabled) {
|
||||
void startAgentLocalServer();
|
||||
void refreshLaunchers();
|
||||
}
|
||||
void sdkSetup.evaluate(ws);
|
||||
void ws.initialize().then(() => {
|
||||
void sdkSetup.evaluate(ws);
|
||||
if (agentAccessEnabled) void startAgentLocalServer();
|
||||
if (agentAccessEnabled) {
|
||||
void startAgentLocalServer();
|
||||
void refreshLaunchers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
LIVE_ONLY,
|
||||
liveArgsFor,
|
||||
parseArgs,
|
||||
toolNameFor,
|
||||
} from "../out/agent/cli.js";
|
||||
|
||||
// ── Argument parsing and command mapping (pure) ───────────────────────
|
||||
|
||||
test("parseArgs reads the project, snapshot and command", () => {
|
||||
const options = parseArgs(["--project", "D:/Mods/Alpha", "find", "AthenaCannon", "GameObject"]);
|
||||
assert.equal(options.projectDir, "D:/Mods/Alpha");
|
||||
assert.equal(options.command, "find");
|
||||
assert.deepEqual(options.args, ["AthenaCannon", "GameObject"]);
|
||||
});
|
||||
|
||||
test("parseArgs ignores unknown flags without failing the call", () => {
|
||||
// An agent probing with a flag we do not know yet must not crash the CLI.
|
||||
const withCommand = parseArgs(["--json", "status"]);
|
||||
assert.equal(withCommand.command, "status");
|
||||
const trailing = parseArgs(["find", "X", "--future-flag"]);
|
||||
assert.equal(trailing.command, "find");
|
||||
assert.deepEqual(trailing.args, ["X"]);
|
||||
// A stray positional is still treated as the command name.
|
||||
assert.equal(parseArgs([]).command, "status");
|
||||
});
|
||||
|
||||
test("parseArgs reads outgoing-specific options", () => {
|
||||
const options = parseArgs([
|
||||
"outgoing", "AthenaCannon", "GameObject",
|
||||
"--depth", "2",
|
||||
"--target-types", "WeaponTemplate, GameObject",
|
||||
"--max-edges", "25",
|
||||
"--include-unresolved",
|
||||
]);
|
||||
assert.equal(options.command, "outgoing");
|
||||
assert.equal(options.depth, 2);
|
||||
assert.deepEqual(options.targetTypes, ["WeaponTemplate", "GameObject"]);
|
||||
assert.equal(options.maxEdges, 25);
|
||||
assert.equal(options.includeUnresolved, true);
|
||||
});
|
||||
|
||||
test("toolNameFor maps every CLI command to a live tool", () => {
|
||||
assert.equal(toolNameFor("find"), "find_asset");
|
||||
assert.equal(toolNameFor("refs"), "find_references");
|
||||
assert.equal(toolNameFor("outgoing"), "get_asset_references");
|
||||
assert.equal(toolNameFor("list"), "list_assets_by_type");
|
||||
assert.equal(toolNameFor("active"), "is_file_active");
|
||||
assert.equal(toolNameFor("define"), "find_define");
|
||||
assert.equal(toolNameFor("resolve"), "resolve_include");
|
||||
assert.equal(toolNameFor("projects"), "list_projects");
|
||||
assert.equal(toolNameFor("status"), "get_status");
|
||||
});
|
||||
|
||||
test("LIVE_ONLY covers exactly the commands needing DOM/project context", () => {
|
||||
assert.equal(LIVE_ONLY.has("outgoing"), true);
|
||||
assert.equal(LIVE_ONLY.has("projects"), true);
|
||||
assert.equal(LIVE_ONLY.has("find"), false);
|
||||
assert.equal(LIVE_ONLY.has("status"), false);
|
||||
});
|
||||
|
||||
test("liveArgsFor builds the right payload per command", () => {
|
||||
assert.deepEqual(
|
||||
liveArgsFor({ command: "find", args: ["X", "GameObject"] }),
|
||||
{ id: "X", type: "GameObject" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
liveArgsFor({ command: "active", args: ["D:/f.xml"] }),
|
||||
{ path: "D:/f.xml" },
|
||||
);
|
||||
// Outgoing omits unset options so the server applies its own defaults.
|
||||
const outgoing = liveArgsFor({ command: "outgoing", args: ["X"] });
|
||||
assert.equal(outgoing.id, "X");
|
||||
assert.equal(outgoing.depth, undefined);
|
||||
assert.equal(outgoing.targetTypes, undefined);
|
||||
assert.equal(outgoing.maxEdges, undefined);
|
||||
// An explicit depth of 0 is falsy but must still be forwarded.
|
||||
const zero = liveArgsFor({ command: "outgoing", args: ["X"], depth: 0 });
|
||||
assert.equal(zero.depth, 0);
|
||||
});
|
||||
|
||||
// ── Project inference from the current directory ──────────────────────
|
||||
|
||||
function makeModProject() {
|
||||
const root = mkdtempSync(join(tmpdir(), "ra3-cli-proj-"));
|
||||
mkdirSync(join(root, "Data"), { recursive: true });
|
||||
writeFileSync(join(root, "Data", "Mod.xml"), "<AssetDeclaration/>");
|
||||
return root;
|
||||
}
|
||||
|
||||
test("the CLI finds the project root by walking up from the cwd", async () => {
|
||||
const root = makeModProject();
|
||||
const nested = join(root, "Data", "Allied", "Units");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
const previous = process.cwd();
|
||||
try {
|
||||
process.chdir(nested);
|
||||
const options = parseArgs([]);
|
||||
const resolved = (await import("../out/agent/cli.js")).resolveProjectDir(options);
|
||||
assert.equal(resolved?.toLowerCase(), root.toLowerCase());
|
||||
} finally {
|
||||
process.chdir(previous);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveProjectDir prefers an explicit --project over the cwd", async () => {
|
||||
const { resolveProjectDir } = await import("../out/agent/cli.js");
|
||||
const options = parseArgs(["--project", "D:/Somewhere/Else", "status"]);
|
||||
assert.equal(resolveProjectDir(options), join("D:\\Somewhere\\Else").replace(/\\/g, "\\"));
|
||||
});
|
||||
|
||||
// ── End-to-end execution (skipped when the sandbox forbids spawning) ──
|
||||
|
||||
function canSpawnShell() {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
execFileSync("cmd.exe", ["/d", "/c", "exit 0"], { stdio: "ignore" });
|
||||
} else {
|
||||
execFileSync("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" });
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const spawnable = canSpawnShell();
|
||||
const cliPath = join(process.cwd(), "dist", "agent", "cli.js");
|
||||
|
||||
test("CLI exits 3 with a clear message outside a project", { skip: !spawnable || !existsSync(cliPath) }, (t) => {
|
||||
const empty = mkdtempSync(join(tmpdir(), "ra3-cli-empty-"));
|
||||
try {
|
||||
let code = 0;
|
||||
let stderr = "";
|
||||
try {
|
||||
execFileSync(process.execPath, [cliPath, "status"], {
|
||||
cwd: empty,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.code === "EPERM") {
|
||||
t.skip("sandbox forbids spawning");
|
||||
return;
|
||||
}
|
||||
code = err.status;
|
||||
stderr = err.stderr ?? "";
|
||||
}
|
||||
assert.equal(code, 3);
|
||||
assert.match(stderr, /No project found/);
|
||||
} finally {
|
||||
rmSync(empty, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI reports a live-only command as unavailable instead of empty", { skip: !spawnable || !existsSync(cliPath) }, (t) => {
|
||||
const empty = mkdtempSync(join(tmpdir(), "ra3-cli-liveonly-"));
|
||||
try {
|
||||
let stdout = "";
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, [cliPath, "--project", "D:/Mods/Nope", "outgoing", "X"], {
|
||||
cwd: empty,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.code === "EPERM") {
|
||||
t.skip("sandbox forbids spawning");
|
||||
return;
|
||||
}
|
||||
stdout = err.stdout ?? "";
|
||||
}
|
||||
const payload = JSON.parse(stdout);
|
||||
assert.equal(payload.source, "unavailable");
|
||||
// Must explain why, never look like "this asset has no references".
|
||||
assert.match(payload.error, /requires a live index/);
|
||||
} finally {
|
||||
rmSync(empty, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
INSTANCE_SCHEMA_VERSION,
|
||||
clearInstance,
|
||||
instanceFileName,
|
||||
instancesDir,
|
||||
makeInstanceId,
|
||||
manifestPath,
|
||||
projectsOf,
|
||||
pruneInstances,
|
||||
readInstances,
|
||||
readManifest,
|
||||
writeInstance,
|
||||
writeManifest,
|
||||
} from "../out/agent/instances.js";
|
||||
|
||||
function instanceHome() {
|
||||
return mkdtempSync(join(tmpdir(), "ra3-instances-"));
|
||||
}
|
||||
|
||||
function makeInstance(id, pid = process.pid) {
|
||||
return {
|
||||
instanceId: id,
|
||||
url: `http://127.0.0.1:${10000 + (pid % 1000)}`,
|
||||
token: `tok-${id}`,
|
||||
projectDir: "D:/Mods/Alpha",
|
||||
projects: ["D:/Mods/Alpha"],
|
||||
processId: pid,
|
||||
};
|
||||
}
|
||||
|
||||
test("instances are written and read back", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
const file = await writeInstance(makeInstance("a-1"), home);
|
||||
assert.ok(existsSync(file));
|
||||
assert.ok(file.includes(instancesDir(home)));
|
||||
assert.equal(file.endsWith(instanceFileName("a-1")), true);
|
||||
|
||||
const all = await readInstances(home);
|
||||
assert.equal(all.length, 1);
|
||||
assert.equal(all[0].instanceId, "a-1");
|
||||
assert.equal(all[0].token, "tok-a-1");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent windows do not overwrite each other", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
// Two windows enabling agent access at the same time: separate files, so
|
||||
// there is no read-modify-write race to guard.
|
||||
await writeInstance(makeInstance("win-a"), home);
|
||||
await writeInstance(makeInstance("win-b"), home);
|
||||
const ids = (await readInstances(home)).map((i) => i.instanceId).sort();
|
||||
assert.deepEqual(ids, ["win-a", "win-b"]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("pruneInstances removes entries whose PID is dead", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
// 0x7fffffff is not a valid Windows PID, so it cannot be alive.
|
||||
await writeInstance(makeInstance("alive", process.pid), home);
|
||||
await writeInstance(makeInstance("dead", 0x7fffffff), home);
|
||||
|
||||
const result = await pruneInstances(home);
|
||||
assert.deepEqual(result.removed, ["dead"]);
|
||||
assert.deepEqual(
|
||||
result.kept.map((i) => i.instanceId),
|
||||
["alive"],
|
||||
);
|
||||
// The dead file must actually be gone from disk.
|
||||
assert.equal(existsSync(join(instancesDir(home), instanceFileName("dead"))), false);
|
||||
assert.equal((await readInstances(home)).length, 1);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("pruneInstances keeps entries with an unknown PID", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
// An older/simpler instance file without processId must never be pruned.
|
||||
await writeInstance(
|
||||
{ instanceId: "no-pid", url: "http://127.0.0.1:1", token: "t" },
|
||||
home,
|
||||
);
|
||||
const result = await pruneInstances(home);
|
||||
assert.deepEqual(result.removed, []);
|
||||
assert.equal(result.kept.length, 1);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clearInstance only removes its own file", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
await writeInstance(makeInstance("mine"), home);
|
||||
await writeInstance(makeInstance("theirs"), home);
|
||||
await clearInstance("mine", home);
|
||||
const ids = (await readInstances(home)).map((i) => i.instanceId);
|
||||
assert.deepEqual(ids, ["theirs"]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readInstances tolerates corrupt and unrelated files", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
await writeInstance(makeInstance("good"), home);
|
||||
writeFileSync(join(instancesDir(home), "broken.json"), "{ not json");
|
||||
writeFileSync(join(instancesDir(home), "notes.txt"), "ignore me");
|
||||
const all = await readInstances(home);
|
||||
assert.deepEqual(all.map((i) => i.instanceId), ["good"]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("projectsOf unions projects across instances without duplicates", () => {
|
||||
const projects = projectsOf([
|
||||
{ instanceId: "a", url: "u", token: "t", projectDir: "D:/Mods/Alpha", projects: ["D:/Mods/Alpha"] },
|
||||
{ instanceId: "b", url: "u", token: "t", projectDir: "D:/Mods/Beta", projects: ["D:/Mods/beta", "D:/Mods/Gamma"] },
|
||||
]);
|
||||
// "beta" appears twice with different casing and must collapse to one entry,
|
||||
// keeping the first spelling seen.
|
||||
assert.deepEqual(projects, ["D:/Mods/Alpha", "D:/Mods/beta", "D:/Mods/Gamma"]);
|
||||
});
|
||||
|
||||
test("writeManifest produces a discovery manifest without tokens", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
const manifest = await writeManifest(
|
||||
[makeInstance("a-1"), makeInstance("b-2")],
|
||||
home,
|
||||
);
|
||||
assert.equal(manifest.schemaVersion, INSTANCE_SCHEMA_VERSION);
|
||||
assert.deepEqual(manifest.projects, ["D:/Mods/Alpha"]);
|
||||
assert.equal(manifest.instances.length, 2);
|
||||
|
||||
const raw = readFileSync(manifestPath(home), "utf8");
|
||||
// The manifest is for discovery; secrets must not leak into it.
|
||||
assert.equal(raw.includes("tok-a-1"), false);
|
||||
assert.equal(raw.includes('"token"'), false);
|
||||
|
||||
const reread = await readManifest(home);
|
||||
assert.equal(reread?.instances.length, 2);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("makeInstanceId is unique across rapid calls", () => {
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < 200; i++) ids.add(makeInstanceId(1234));
|
||||
assert.equal(ids.size, 200);
|
||||
for (const id of ids) assert.ok(id.startsWith("1234-"), id);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
LiveClient,
|
||||
findEndpoint,
|
||||
liveUrlForTool,
|
||||
normalizePath,
|
||||
queryLive,
|
||||
responseProjectMismatch,
|
||||
} from "../out/agent/liveClient.js";
|
||||
import { writeInstance } from "../out/agent/instances.js";
|
||||
import { writeEndpoint, writeEndpointForProject } from "../out/agent/endpoint.js";
|
||||
|
||||
const PROJECT_A = "D:/Mods/Alpha";
|
||||
const PROJECT_B = "D:/Mods/Beta";
|
||||
|
||||
function home() {
|
||||
return mkdtempSync(join(tmpdir(), "ra3-liveclient-"));
|
||||
}
|
||||
|
||||
test("live URLs pin the requested project for every tool", () => {
|
||||
const cases = [
|
||||
["get_status", {}],
|
||||
["find_asset", { id: "X" }],
|
||||
["find_references", { id: "X" }],
|
||||
["list_assets_by_type", { type: "GameObject" }],
|
||||
["is_file_active", { path: "D:/f.xml" }],
|
||||
["find_define", { name: "D" }],
|
||||
["resolve_include", { source: "DATA:a.xml" }],
|
||||
["list_projects", {}],
|
||||
];
|
||||
for (const [tool, args] of cases) {
|
||||
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, tool, args);
|
||||
assert.ok(url, `${tool} should have a live URL`);
|
||||
assert.equal(
|
||||
new URL(url).searchParams.get("project"),
|
||||
PROJECT_A,
|
||||
`${tool} must pin the project`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("get_asset_references serialises all of its options", () => {
|
||||
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "get_asset_references", {
|
||||
id: "AthenaCannon",
|
||||
type: "GameObject",
|
||||
depth: 2,
|
||||
targetTypes: ["WeaponTemplate", "GameObject"],
|
||||
maxEdges: 25,
|
||||
includeUnresolved: true,
|
||||
});
|
||||
const q = new URL(url).searchParams;
|
||||
assert.equal(q.get("id"), "AthenaCannon");
|
||||
assert.equal(q.get("type"), "GameObject");
|
||||
assert.equal(q.get("depth"), "2");
|
||||
assert.equal(q.get("targetTypes"), "WeaponTemplate,GameObject");
|
||||
assert.equal(q.get("maxEdges"), "25");
|
||||
assert.equal(q.get("includeUnresolved"), "true");
|
||||
assert.equal(q.get("project"), PROJECT_A);
|
||||
});
|
||||
|
||||
test("unknown tools have no live URL", () => {
|
||||
assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "not_a_tool", {}), null);
|
||||
});
|
||||
|
||||
test("responseProjectMismatch refuses another project's answer", () => {
|
||||
assert.equal(
|
||||
responseProjectMismatch({ index: { projectDir: "d:/mods/alpha" } }, PROJECT_A),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
responseProjectMismatch({ index: { projectDir: PROJECT_B } }, PROJECT_A),
|
||||
true,
|
||||
);
|
||||
// Nothing to compare against: not a mismatch.
|
||||
assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false);
|
||||
assert.equal(responseProjectMismatch({ index: { projectDir: PROJECT_B } }, null), false);
|
||||
});
|
||||
|
||||
test("normalizePath ignores case and trailing separators", () => {
|
||||
assert.equal(normalizePath("D:\\Mods\\Alpha\\"), normalizePath("d:/mods/alpha"));
|
||||
});
|
||||
|
||||
test("findEndpoint prefers the per-project endpoint", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
await writeEndpointForProject(
|
||||
PROJECT_A,
|
||||
{ url: "http://127.0.0.1:1111", token: "tok-a", processId: process.pid },
|
||||
h,
|
||||
);
|
||||
const endpoint = await findEndpoint({ projectDir: PROJECT_A, agentHome: h });
|
||||
assert.equal(endpoint?.url, "http://127.0.0.1:1111");
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findEndpoint finds a live instance when no per-project file exists", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
// A window that has not yet written per-project endpoints, only its own
|
||||
// instance file. Discovery must still find it.
|
||||
await writeInstance(
|
||||
{
|
||||
instanceId: "w1",
|
||||
url: "http://127.0.0.1:2222",
|
||||
token: "tok-inst",
|
||||
projects: [PROJECT_A, PROJECT_B],
|
||||
processId: process.pid,
|
||||
},
|
||||
h,
|
||||
);
|
||||
const endpoint = await findEndpoint({ projectDir: PROJECT_B, agentHome: h });
|
||||
assert.equal(endpoint?.url, "http://127.0.0.1:2222");
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findEndpoint ignores instances that do not serve the project", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
await writeInstance(
|
||||
{
|
||||
instanceId: "other",
|
||||
url: "http://127.0.0.1:3333",
|
||||
token: "tok",
|
||||
projects: ["D:/Mods/Unrelated"],
|
||||
processId: process.pid,
|
||||
},
|
||||
h,
|
||||
);
|
||||
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findEndpoint ignores dead instances", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
await writeInstance(
|
||||
{
|
||||
instanceId: "dead",
|
||||
url: "http://127.0.0.1:4444",
|
||||
token: "tok",
|
||||
projects: [PROJECT_A],
|
||||
processId: 0x7fffffff,
|
||||
},
|
||||
h,
|
||||
);
|
||||
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findEndpoint does not use a global endpoint recording another project", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
await writeEndpoint(
|
||||
{ url: "http://127.0.0.1:5555", token: "tok", projectDir: PROJECT_B, processId: process.pid },
|
||||
h,
|
||||
);
|
||||
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
|
||||
// It is still usable when asked for its own project.
|
||||
assert.ok(await findEndpoint({ projectDir: PROJECT_B, agentHome: h }));
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a query with no reachable live instance returns null", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
assert.equal(
|
||||
await queryLive("get_status", {}, { projectDir: PROJECT_A, agentHome: h }),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the negative cache suppresses repeated attempts and can be reset", async () => {
|
||||
let now = 1_000_000;
|
||||
const client = new LiveClient({ projectDir: PROJECT_A, now: () => now });
|
||||
assert.equal(client.suppressed, false);
|
||||
client.markUnavailable();
|
||||
assert.equal(client.suppressed, true);
|
||||
// Still suppressed just before the cooldown expires.
|
||||
now += 4999;
|
||||
assert.equal(client.suppressed, true);
|
||||
// Expired afterwards.
|
||||
now += 2;
|
||||
assert.equal(client.suppressed, false);
|
||||
|
||||
client.markUnavailable();
|
||||
client.reset();
|
||||
assert.equal(client.suppressed, false);
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* End-to-end live-path test.
|
||||
*
|
||||
* Starts a real local HTTP live server (the same module the extension runs),
|
||||
* registers it exactly the way the extension does (per-project endpoint +
|
||||
* instance file + merged manifest), and then drives it through the shared
|
||||
* `LiveClient` that both the MCP server and the CLI use.
|
||||
*
|
||||
* This is the in-process equivalent of "start the extension, then query it
|
||||
* from the CLI/MCP", so it covers the transport, project pinning, project
|
||||
* resolution and cross-project refusal without needing to spawn anything.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { startLocalServer } from "../out/agent/localServer.js";
|
||||
import {
|
||||
clearInstance,
|
||||
readManifest,
|
||||
pruneInstances,
|
||||
writeInstance,
|
||||
writeManifest,
|
||||
} from "../out/agent/instances.js";
|
||||
import { writeEndpointForProject } from "../out/agent/endpoint.js";
|
||||
import { LiveClient, findEndpoint } from "../out/agent/liveClient.js";
|
||||
import { parseLoadedXml } from "../out/agent/forwardRefs.js";
|
||||
|
||||
const PROJECT_A = "D:/Mods/Alpha";
|
||||
const PROJECT_B = "D:/Mods/Beta";
|
||||
|
||||
function def(type, id, file, line) {
|
||||
return { type, id, file, line, origin: "project", stream: "static" };
|
||||
}
|
||||
|
||||
const UNIT_FILE = `${PROJECT_A}/Data/AthenaCannon.xml`;
|
||||
const BASE_FILE = `${PROJECT_A}/Data/BaseCannon.xml`;
|
||||
const UNIT = def("GameObject", "AthenaCannon", UNIT_FILE, 2);
|
||||
const BASE = def("GameObject", "BaseCannon", BASE_FILE, 2);
|
||||
const WEAPON = def("WeaponTemplate", "AthenaCannonWeapon", `${PROJECT_A}/Data/Weapon.xml`, 88);
|
||||
|
||||
const ATHENA_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
|
||||
<CreateObjectDie><CreateObject>AthenaCannon_Die</CreateObject></CreateObjectDie>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const BASE_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="BaseCannon">
|
||||
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="AthenaCannonWeapon" /></WeaponSlotHardpoint></WeaponSetUpdate>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const XML_FILES = {
|
||||
[UNIT_FILE]: ATHENA_XML,
|
||||
[BASE_FILE]: BASE_XML,
|
||||
};
|
||||
|
||||
function makeIndex(projectDir) {
|
||||
const assets = new Map([
|
||||
["GameObject", new Map([["athenacannon", [UNIT]], ["basecannon", [BASE]]])],
|
||||
["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON]]])],
|
||||
]);
|
||||
return {
|
||||
projectDir,
|
||||
sdkDir: "",
|
||||
complete: true,
|
||||
phase: "art",
|
||||
stale: false,
|
||||
assets,
|
||||
assetsById: new Map([
|
||||
["athenacannon", [UNIT]],
|
||||
["basecannon", [BASE]],
|
||||
["athenacannonweapon", [WEAPON]],
|
||||
]),
|
||||
defines: new Map([
|
||||
["d", [{ name: "D", value: "1", file: UNIT_FILE, line: 1, origin: "project" }]],
|
||||
]),
|
||||
files: new Map(),
|
||||
streams: [
|
||||
{
|
||||
name: "static",
|
||||
entry: `${projectDir}/Data/Mod.xml`,
|
||||
files: new Set([UNIT_FILE.toLowerCase().replace(/\\/g, "/")]),
|
||||
},
|
||||
],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
references: new Map([
|
||||
[
|
||||
`GameObject\u0000athenacannon\u0000${UNIT_FILE}\u00002`,
|
||||
[{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }],
|
||||
],
|
||||
]),
|
||||
recordsHashes: new Map(),
|
||||
stats: {
|
||||
projectDir, sdkDir: "", phase: "art", complete: true,
|
||||
indexedFiles: 2, parsedFiles: 2, shallowScannedFiles: 0, deferredArtFiles: 0,
|
||||
shallowCacheHits: 0, recordsCacheHits: 0, resolveCacheHits: 0, resolveCalls: 0,
|
||||
snapshotHits: 0, snapshotFallbacks: 0, candidatesMs: 0, walkMs: 0, artScanMs: 0,
|
||||
assetCount: 3, referenceCount: 1, defineCount: 1, manifestFiles: 0,
|
||||
manifestAssetCount: 0, streams: 1, sourceCandidates: 0, elapsedMs: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Boots a live server + registration files, and returns a cleanup handle. */
|
||||
async function bootLive() {
|
||||
const agentHome = mkdtempSync(join(tmpdir(), "ra3-e2e-"));
|
||||
const handle = await startLocalServer({
|
||||
getIndex: (projectDir) =>
|
||||
!projectDir || projectDir === PROJECT_A ? makeIndex(PROJECT_A) : null,
|
||||
listProjects: () => [PROJECT_A, PROJECT_B],
|
||||
loadFile: async (file) => {
|
||||
const text = XML_FILES[file];
|
||||
return text ? parseLoadedXml(text) : null;
|
||||
},
|
||||
});
|
||||
const endpoint = {
|
||||
instanceId: "e2e-1",
|
||||
url: `http://127.0.0.1:${handle.port}`,
|
||||
token: handle.token,
|
||||
projectDir: PROJECT_A,
|
||||
projects: [PROJECT_A, PROJECT_B],
|
||||
processId: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeEndpointForProject(PROJECT_A, { ...endpoint, projectDir: PROJECT_A }, agentHome);
|
||||
await writeInstance(endpoint, agentHome);
|
||||
await writeManifest([endpoint], agentHome);
|
||||
return {
|
||||
agentHome,
|
||||
endpoint,
|
||||
async close() {
|
||||
await handle.close();
|
||||
rmSync(agentHome, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("LiveClient reaches a real live server and answers every snapshot tool", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
|
||||
|
||||
const status = await client.query("get_status");
|
||||
assert.equal(status?.mismatched, false);
|
||||
assert.equal(status.payload.state, "ready");
|
||||
assert.equal(status.payload.projectDir, PROJECT_A);
|
||||
|
||||
const found = await client.query("find_asset", { id: "AthenaCannon", type: "GameObject" });
|
||||
assert.equal(found.payload.data.length, 1);
|
||||
|
||||
const refs = await client.query("find_references", { id: "AthenaCannon" });
|
||||
assert.equal(refs.payload.data.length, 1);
|
||||
|
||||
const active = await client.query("is_file_active", { path: UNIT_FILE });
|
||||
assert.equal(active.payload.data.active, true);
|
||||
|
||||
const define = await client.query("find_define", { name: "D" });
|
||||
assert.equal(define.payload.data.length, 1);
|
||||
|
||||
const list = await client.query("list_assets_by_type", { type: "GameObject" });
|
||||
assert.equal(list.payload.data.length, 2);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the live path answers get_asset_references with element provenance", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
|
||||
const result = await client.query("get_asset_references", {
|
||||
id: "AthenaCannon",
|
||||
type: "GameObject",
|
||||
targetTypes: ["WeaponTemplate"],
|
||||
});
|
||||
const data = result.payload.data;
|
||||
assert.ok(data, "expected edge data");
|
||||
|
||||
// The weapon is written in BaseCannon's XML, reached through inheritFrom.
|
||||
const weapon = data.edges.find((e) => e.to?.id === "AthenaCannonWeapon");
|
||||
assert.ok(weapon, "expected the inherited weapon edge");
|
||||
assert.equal(weapon.via.element, "Weapon");
|
||||
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
|
||||
assert.equal(weapon.definedIn.id, "BaseCannon");
|
||||
assert.equal(weapon.source.file, BASE_FILE);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("list_projects reports the live project roots", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
|
||||
const result = await client.query("list_projects");
|
||||
assert.deepEqual(result.payload.data, [PROJECT_A, PROJECT_B]);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a client for an unknown project cannot use another project's answer", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
// The registered instance only serves PROJECT_A/PROJECT_B, so a client
|
||||
// pinned to a third project must find nothing at all rather than fall
|
||||
// back to PROJECT_A's data.
|
||||
const outsider = new LiveClient({
|
||||
projectDir: "D:/Mods/Unrelated",
|
||||
agentHome: live.agentHome,
|
||||
});
|
||||
assert.equal(await outsider.query("find_asset", { id: "AthenaCannon" }), null);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("discovery works through the instance file alone (no per-project endpoint)", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
// Simulate a window that registered its instance but whose per-project
|
||||
// endpoint has not been written yet.
|
||||
rmSync(join(live.agentHome, "endpoints"), { recursive: true, force: true });
|
||||
const endpoint = await findEndpoint({
|
||||
projectDir: PROJECT_A,
|
||||
agentHome: live.agentHome,
|
||||
});
|
||||
assert.equal(endpoint?.instanceId, "e2e-1");
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the merged manifest lists projects for discovery", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
const manifest = await readManifest(live.agentHome);
|
||||
assert.ok(manifest);
|
||||
assert.deepEqual(new Set(manifest.projects), new Set([PROJECT_A, PROJECT_B]));
|
||||
assert.equal(manifest.instances.length, 1);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a crashed instance is pruned by a later instance and then unreachable", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
// Add a second instance that looks crashed.
|
||||
await writeInstance(
|
||||
{
|
||||
instanceId: "dead-window",
|
||||
url: "http://127.0.0.1:1",
|
||||
token: "t",
|
||||
projects: [PROJECT_A],
|
||||
processId: 0x7fffffff,
|
||||
},
|
||||
live.agentHome,
|
||||
);
|
||||
const pruned = await pruneInstances(live.agentHome);
|
||||
assert.ok(pruned.removed.includes("dead-window"));
|
||||
// The surviving instance still works after the prune.
|
||||
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
|
||||
const status = await client.query("get_status");
|
||||
assert.equal(status.payload.state, "ready");
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("clearing this window's instance leaves other windows untouched", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
await writeInstance(
|
||||
{
|
||||
instanceId: "other-window",
|
||||
url: "http://127.0.0.1:9999",
|
||||
token: "t2",
|
||||
projects: [PROJECT_A],
|
||||
processId: process.pid,
|
||||
},
|
||||
live.agentHome,
|
||||
);
|
||||
await clearInstance("e2e-1", live.agentHome);
|
||||
const { readInstances } = await import("../out/agent/instances.js");
|
||||
const ids = (await readInstances(live.agentHome)).map((i) => i.instanceId);
|
||||
assert.deepEqual(ids, ["other-window"]);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
@@ -1,73 +1,22 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
liveUrlForTool,
|
||||
normalizePath,
|
||||
responseProjectMismatch,
|
||||
} from "../out/agent/mcpServer.js";
|
||||
|
||||
const PROJECT_A = "D:/Mods/Example";
|
||||
// The URL building, project pinning and cross-project refusal now live in
|
||||
// `liveClient` and are covered by agentLiveClient.test.mjs. What remains
|
||||
// MCP-layer-specific is that the server module is *importable*: it must not
|
||||
// start its stdio loop as a side effect of being imported, otherwise tests and
|
||||
// any tool that merely inspects the module would hang waiting on stdin.
|
||||
|
||||
test("live URLs always pin the requested project", () => {
|
||||
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "find_asset", {
|
||||
id: "AthenaCannon",
|
||||
type: "GameObject",
|
||||
});
|
||||
assert.ok(url);
|
||||
const parsed = new URL(url);
|
||||
assert.equal(parsed.pathname, "/find_asset");
|
||||
assert.equal(parsed.searchParams.get("project"), PROJECT_A);
|
||||
assert.equal(parsed.searchParams.get("id"), "AthenaCannon");
|
||||
assert.equal(parsed.searchParams.get("type"), "GameObject");
|
||||
test("the MCP server module imports without starting its stdio loop", async () => {
|
||||
const mod = await import("../out/agent/mcpServer.js");
|
||||
assert.equal(typeof mod, "object");
|
||||
// Reaching this line proves main() did not run on import.
|
||||
});
|
||||
|
||||
test("get_asset_references serialises its list and scalar options", () => {
|
||||
const url = liveUrlForTool("http://127.0.0.1:1234/", PROJECT_A, "get_asset_references", {
|
||||
id: "AthenaCannon",
|
||||
depth: 2,
|
||||
targetTypes: ["WeaponTemplate", "GameObject"],
|
||||
maxEdges: 25,
|
||||
includeUnresolved: true,
|
||||
});
|
||||
assert.ok(url);
|
||||
const parsed = new URL(url);
|
||||
assert.equal(parsed.pathname, "/get_asset_references");
|
||||
assert.equal(parsed.searchParams.get("depth"), "2");
|
||||
assert.equal(parsed.searchParams.get("targetTypes"), "WeaponTemplate,GameObject");
|
||||
assert.equal(parsed.searchParams.get("maxEdges"), "25");
|
||||
assert.equal(parsed.searchParams.get("includeUnresolved"), "true");
|
||||
assert.equal(parsed.searchParams.get("project"), PROJECT_A);
|
||||
});
|
||||
|
||||
test("live URLs omit the project selector when none is configured", () => {
|
||||
const url = liveUrlForTool("http://127.0.0.1:1234", null, "get_status", {});
|
||||
assert.equal(url, "http://127.0.0.1:1234/status");
|
||||
});
|
||||
|
||||
test("unknown tools have no live URL", () => {
|
||||
assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "nope", {}), null);
|
||||
});
|
||||
|
||||
test("responseProjectMismatch flags answers from another project", () => {
|
||||
// Matching project (case/separator-insensitive) is accepted.
|
||||
assert.equal(
|
||||
responseProjectMismatch({ index: { projectDir: "d:/mods/example" } }, PROJECT_A),
|
||||
false,
|
||||
);
|
||||
// A different project must be rejected: this is the multi-window cross-talk guard.
|
||||
assert.equal(
|
||||
responseProjectMismatch({ index: { projectDir: "D:/Mods/Other" } }, PROJECT_A),
|
||||
true,
|
||||
);
|
||||
// No project configured, or no projectDir in the payload: nothing to check.
|
||||
assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false);
|
||||
assert.equal(
|
||||
responseProjectMismatch({ index: { projectDir: "D:/Mods/Other" } }, null),
|
||||
false,
|
||||
);
|
||||
assert.equal(responseProjectMismatch(null, PROJECT_A), false);
|
||||
});
|
||||
|
||||
test("normalizePath is case- and separator-insensitive", () => {
|
||||
assert.equal(normalizePath("D:\\Mods\\Example\\"), normalizePath("d:/mods/example"));
|
||||
test("the CLI module imports without running", async () => {
|
||||
const mod = await import("../out/agent/cli.js");
|
||||
assert.equal(typeof mod.parseArgs, "function");
|
||||
assert.equal(typeof mod.toolNameFor, "function");
|
||||
assert.equal(typeof mod.liveArgsFor, "function");
|
||||
assert.ok(mod.LIVE_ONLY instanceof Set);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
electronExecutableCandidates,
|
||||
electronRuntime,
|
||||
findElectronExecutable,
|
||||
isElectronHost,
|
||||
isNodeFreeLauncher,
|
||||
launcherScript,
|
||||
looksLikeElectronExecutable,
|
||||
resolveRuntime,
|
||||
} from "../out/agent/runtime.js";
|
||||
import { writeLauncher } from "../out/agent/setup.js";
|
||||
|
||||
const WIN = "win32";
|
||||
const LINUX = "linux";
|
||||
|
||||
// ── Pure launcher generation ──────────────────────────────────────────
|
||||
|
||||
test("electron runtime launcher runs without Node on PATH", () => {
|
||||
const script = launcherScript(
|
||||
{
|
||||
runtime: electronRuntime("C:\\Apps\\VSCode\\Code.exe"),
|
||||
serverPath: "C:\\ext\\dist\\agent\\mcpServer.js",
|
||||
projectDir: "D:\\Mods\\Example",
|
||||
},
|
||||
WIN,
|
||||
);
|
||||
assert.ok(script.startsWith("@echo off"));
|
||||
assert.ok(script.includes("set ELECTRON_RUN_AS_NODE=1"));
|
||||
assert.ok(script.includes("C:\\Apps\\VSCode\\Code.exe"));
|
||||
assert.ok(script.includes("C:\\ext\\dist\\agent\\mcpServer.js"));
|
||||
// The Node path exists but must be guarded by the runtime-existence jump.
|
||||
assert.ok(script.includes('if not exist "%RA3_RUNTIME%" goto :ra3_node'));
|
||||
assert.ok(script.includes(":ra3_node"));
|
||||
assert.equal(isNodeFreeLauncher(script, WIN), true);
|
||||
});
|
||||
|
||||
test("Windows launcher avoids the parse-time %errorlevel% batch pitfall", () => {
|
||||
const script = launcherScript(
|
||||
{
|
||||
runtime: electronRuntime("C:\\Apps\\VSCode\\Code.exe"),
|
||||
serverPath: "C:\\ext\\mcpServer.js",
|
||||
projectDir: "D:\\P",
|
||||
},
|
||||
WIN,
|
||||
);
|
||||
// Inside a parenthesised block %errorlevel% would expand at parse time.
|
||||
assert.ok(!/^if exist .*\(\s*$/m.test(script), "must not use an if (...) block");
|
||||
assert.equal(
|
||||
(script.match(/exit \/b %errorlevel%/g) ?? []).length,
|
||||
2,
|
||||
"both Electron and Node endings should propagate the exit code",
|
||||
);
|
||||
});
|
||||
|
||||
test("node runtime launcher is generated when Electron is unavailable", () => {
|
||||
const script = launcherScript(
|
||||
{
|
||||
runtime: { kind: "node", executable: "node", env: {}, viaPath: true },
|
||||
serverPath: "C:\\ext\\mcpServer.js",
|
||||
projectDir: "D:\\P",
|
||||
},
|
||||
WIN,
|
||||
);
|
||||
assert.ok(!script.includes("ELECTRON_RUN_AS_NODE"));
|
||||
assert.ok(script.includes('set "RA3_NODE=node"'));
|
||||
assert.equal(isNodeFreeLauncher(script, WIN), false);
|
||||
});
|
||||
|
||||
test("POSIX launcher prefers Electron and falls back to Node", () => {
|
||||
const script = launcherScript(
|
||||
{
|
||||
runtime: electronRuntime("/usr/share/code/code"),
|
||||
serverPath: "/ext/mcpServer.js",
|
||||
projectDir: "/mods/example",
|
||||
},
|
||||
LINUX,
|
||||
);
|
||||
assert.ok(script.startsWith("#!/usr/bin/env sh"));
|
||||
assert.ok(script.includes("ELECTRON_RUN_AS_NODE=1 exec"));
|
||||
assert.ok(script.includes('if [ -x "$RA3_RUNTIME" ]'));
|
||||
assert.equal(isNodeFreeLauncher(script, LINUX), true);
|
||||
});
|
||||
|
||||
test("launcher quotes paths safely", () => {
|
||||
const win = launcherScript(
|
||||
{
|
||||
runtime: electronRuntime("C:\\Program Files\\VS Code\\Code.exe"),
|
||||
serverPath: "C:\\my ext\\mcpServer.js",
|
||||
projectDir: "D:\\My Mods\\Example",
|
||||
},
|
||||
WIN,
|
||||
);
|
||||
assert.ok(win.includes('set "RA3_RUNTIME=C:\\Program Files\\VS Code\\Code.exe"'));
|
||||
assert.ok(win.includes('set "RA3_SERVER=C:\\my ext\\mcpServer.js"'));
|
||||
|
||||
const sh = launcherScript(
|
||||
{
|
||||
runtime: electronRuntime("/opt/it's here/code"),
|
||||
serverPath: "/tmp/server.js",
|
||||
projectDir: "/tmp/proj",
|
||||
},
|
||||
LINUX,
|
||||
);
|
||||
// Single quotes inside a single-quoted POSIX string must be escaped.
|
||||
assert.ok(sh.includes(`'/opt/it'\\''s here/code'`));
|
||||
});
|
||||
|
||||
test("looksLikeElectronExecutable recognises VS Code-family binaries", () => {
|
||||
assert.equal(looksLikeElectronExecutable("C:\\...\\Microsoft VS Code\\Code.exe"), true);
|
||||
assert.equal(looksLikeElectronExecutable("/usr/share/code/code"), true);
|
||||
assert.equal(looksLikeElectronExecutable("/Applications/Visual Studio Code.app/Contents/MacOS/Electron"), true);
|
||||
assert.equal(looksLikeElectronExecutable("C:\\Windows\\System32\\cmd.exe"), false);
|
||||
});
|
||||
|
||||
test("runtime resolution falls back to Node outside an Electron host", () => {
|
||||
// Tests run under plain Node, so the resolved runtime must be Node.
|
||||
assert.equal(isElectronHost(), false);
|
||||
const runtime = resolveRuntime();
|
||||
assert.equal(runtime.kind, "node");
|
||||
assert.equal(runtime.viaPath, true);
|
||||
assert.deepEqual(runtime.env, {});
|
||||
});
|
||||
|
||||
test("electronRuntime carries the ELECTRON_RUN_AS_NODE env", () => {
|
||||
const runtime = electronRuntime("C:\\Code.exe");
|
||||
assert.equal(runtime.kind, "electron");
|
||||
assert.equal(runtime.executable, "C:\\Code.exe");
|
||||
assert.deepEqual(runtime.env, { ELECTRON_RUN_AS_NODE: "1" });
|
||||
assert.equal(runtime.viaPath, false);
|
||||
});
|
||||
|
||||
test("electronExecutableCandidates returns platform-appropriate probes", () => {
|
||||
const candidates = electronExecutableCandidates();
|
||||
assert.ok(Array.isArray(candidates));
|
||||
if (process.platform === "win32") {
|
||||
assert.ok(candidates.every((c) => c.endsWith(".exe")));
|
||||
}
|
||||
// findElectronExecutable must never throw and returns a string or null.
|
||||
const found = findElectronExecutable();
|
||||
assert.ok(found === null || typeof found === "string");
|
||||
});
|
||||
|
||||
// ── writeLauncher integration ─────────────────────────────────────────
|
||||
|
||||
test("writeLauncher writes an executable launcher and reports node-freeness", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-runtime-test-"));
|
||||
try {
|
||||
const result = await writeLauncher("C:\\ext", "D:\\Mods\\Example", home, electronRuntime("C:\\Code.exe"));
|
||||
assert.ok(existsSync(result.path));
|
||||
assert.equal(result.nodeFree, true);
|
||||
assert.equal(result.runtime.kind, "electron");
|
||||
const text = readFileSync(result.path, "utf8");
|
||||
assert.ok(text.includes("ELECTRON_RUN_AS_NODE=1"));
|
||||
assert.ok(text.includes("mcpServer.js"));
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Live launcher execution (skipped when the sandbox forbids spawning) ──
|
||||
|
||||
const electron = findElectronExecutable();
|
||||
|
||||
/**
|
||||
* True when this process may launch the launcher, i.e. spawn a shell.
|
||||
*
|
||||
* Some sandboxes allow spawning `node` directly but deny `cmd.exe`/`sh`, so
|
||||
* the probe must exercise the same capability the test needs instead of just
|
||||
* spawning any child process.
|
||||
*/
|
||||
function canSpawnShell() {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
execFileSync("cmd.exe", ["/d", "/c", "exit 0"], { stdio: "ignore" });
|
||||
} else {
|
||||
execFileSync("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" });
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const spawnable = canSpawnShell();
|
||||
|
||||
test(
|
||||
"generated launcher completes an MCP session on the Electron runtime",
|
||||
{ skip: !spawnable || !electron },
|
||||
(t) => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-launcher-run-"));
|
||||
try {
|
||||
const serverPath = join(process.cwd(), "dist", "agent", "mcpServer.js");
|
||||
const launcher = join(home, process.platform === "win32" ? "launch.cmd" : "launch.sh");
|
||||
writeFileSync(
|
||||
launcher,
|
||||
launcherScript({
|
||||
runtime: electronRuntime(electron),
|
||||
serverPath,
|
||||
projectDir: "D:/Mods/Example",
|
||||
// Point the Node fallback at a path that cannot exist, so a
|
||||
// successful session proves the Electron branch was taken and the
|
||||
// launcher really is Node-free.
|
||||
nodeFallback: join(home, "no-such-node"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const requests = [
|
||||
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
||||
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
|
||||
].join("\n");
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync(launcher, [], {
|
||||
input: requests,
|
||||
encoding: "utf8",
|
||||
timeout: 60000,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.code === "EPERM") {
|
||||
t.skip("sandbox forbids spawning a shell");
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const lines = stdout.split(/\r?\n/).filter((l) => l.trim());
|
||||
const init = JSON.parse(lines[0]);
|
||||
assert.equal(init.result.serverInfo.name, "ra3-mod-xml");
|
||||
const tools = JSON.parse(lines[1]);
|
||||
assert.ok(tools.result.tools.length >= 9);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -56,6 +56,32 @@ test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async (
|
||||
}
|
||||
});
|
||||
|
||||
test("SKILL.md explains how to reach the index without MCP", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-reach-"));
|
||||
try {
|
||||
const skillDir = join(dir, "ra3-mod-xml");
|
||||
await writeSkillTo(skillDir, "0.1.25");
|
||||
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
||||
|
||||
assert.match(content, /## Reaching the index/);
|
||||
// The discovery manifest is the stable entry point.
|
||||
assert.ok(content.includes("~/.ra3modxml/index.json"));
|
||||
// The launcher and the bundled CLI are both mentioned.
|
||||
assert.ok(content.includes("ra3-mod-xml-mcp"));
|
||||
assert.ok(content.includes("cli.js"));
|
||||
// The stdio escape hatch must be shown, since it needs no setup at all.
|
||||
assert.ok(content.includes("tools/call"));
|
||||
// It must not pretend configuring a client takes effect immediately.
|
||||
assert.ok(content.includes("new session"));
|
||||
// And it must refuse to invent results.
|
||||
assert.match(content, /Never fabricate index results/);
|
||||
// Instructions must not send it looking for project-specific docs.
|
||||
assert.equal(content.includes("codebase-navigation-guide"), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("installSkillToDirectories records managed copies and uninstall removes them", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-skill-home-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-target-"));
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Probe: which Node APIs does the VS Code Electron binary provide in
|
||||
// ELECTRON_RUN_AS_NODE mode? Run with:
|
||||
// ELECTRON_RUN_AS_NODE=1 "<Code.exe>" probe.cjs
|
||||
const out = {};
|
||||
|
||||
try {
|
||||
out.nodeVersion = process.versions.node;
|
||||
out.electronVersion = process.versions.electron ?? null;
|
||||
out.isElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE === "1";
|
||||
out.execPath = process.execPath;
|
||||
out.platform = process.platform;
|
||||
} catch (e) {
|
||||
out.baseError = String(e);
|
||||
}
|
||||
|
||||
const modules = [
|
||||
"node:fs",
|
||||
"node:fs/promises",
|
||||
"node:path",
|
||||
"node:os",
|
||||
"node:http",
|
||||
"node:readline",
|
||||
"node:zlib",
|
||||
"node:crypto",
|
||||
"node:util",
|
||||
"node:net",
|
||||
"node:child_process",
|
||||
"node:test",
|
||||
"node:assert",
|
||||
"node:url",
|
||||
"node:events",
|
||||
];
|
||||
out.modules = {};
|
||||
for (const m of modules) {
|
||||
try {
|
||||
require(m);
|
||||
out.modules[m] = "ok";
|
||||
} catch (e) {
|
||||
out.modules[m] = "FAIL: " + (e && e.code ? e.code : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// The APIs the MCP server actually depends on.
|
||||
try {
|
||||
const { parseLoadedXml } = require("../out/agent/forwardRefs.js");
|
||||
const parsed = parseLoadedXml('<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="X"/></AssetDeclaration>');
|
||||
out.forwardRefs = "ok: elements=" + parsed.parse.elements.length;
|
||||
} catch (e) {
|
||||
out.forwardRefs = "FAIL: " + String(e).slice(0, 160);
|
||||
}
|
||||
|
||||
try {
|
||||
const { startLocalServer } = require("../out/agent/localServer.js");
|
||||
out.localServer = typeof startLocalServer === "function" ? "loadable" : "missing";
|
||||
} catch (e) {
|
||||
out.localServer = "FAIL: " + String(e).slice(0, 160);
|
||||
}
|
||||
|
||||
// Async smoke test: gzip round-trip + http listen (both used at runtime).
|
||||
(async () => {
|
||||
try {
|
||||
const { gzip, gunzip } = require("node:zlib");
|
||||
const { promisify } = require("node:util");
|
||||
const buf = await promisify(gzip)(Buffer.from("hello"));
|
||||
const back = await promisify(gunzip)(buf);
|
||||
out.zlibRoundTrip = back.toString() === "hello" ? "ok" : "mismatch";
|
||||
} catch (e) {
|
||||
out.zlibRoundTrip = "FAIL: " + String(e).slice(0, 120);
|
||||
}
|
||||
|
||||
try {
|
||||
const { startLocalServer } = require("../out/agent/localServer.js");
|
||||
const handle = await startLocalServer({
|
||||
getIndex: () => null,
|
||||
listProjects: () => [],
|
||||
token: "probe",
|
||||
});
|
||||
const res = await fetch(`http://127.0.0.1:${handle.port}/status`, {
|
||||
headers: { authorization: "Bearer probe" },
|
||||
});
|
||||
const body = await res.json();
|
||||
out.httpServer = "ok: " + JSON.stringify(body);
|
||||
await handle.close();
|
||||
} catch (e) {
|
||||
out.httpServer = "FAIL: " + String(e).slice(0, 160);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
})();
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Starts a real local live-index server with a fake in-memory index and
|
||||
* registers it exactly like the extension does (instance file + per-project
|
||||
* endpoint + merged manifest). Used by the integration smoke test.
|
||||
*
|
||||
* Prints the agent home as its first stdout line, then stays alive.
|
||||
*/
|
||||
const { startLocalServer } = require("../out/agent/localServer.js");
|
||||
const { writeInstance, writeManifest } = require("../out/agent/instances.js");
|
||||
const { writeEndpoint, writeEndpointForProject } = require("../out/agent/endpoint.js");
|
||||
const { mkdirSync, writeFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
const PROJECT = process.env.RA3_PROJECT || "D:/Mods/Alpha";
|
||||
const OTHER = "D:/Mods/Beta";
|
||||
// The agent home is passed in, so the caller knows it without reading stdout.
|
||||
const home = process.argv[2];
|
||||
if (!home) {
|
||||
console.error("usage: serve-fake-index.cjs <agentHome>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function def(type, id, file, line) {
|
||||
return { type, id, file, line, origin: "project", stream: "static" };
|
||||
}
|
||||
|
||||
const unitFile = `${PROJECT}/Data/AthenaCannon.xml`;
|
||||
const unit = def("GameObject", "AthenaCannon", unitFile, 2);
|
||||
const weapon = def("WeaponTemplate", "AthenaCannonWeapon", `${PROJECT}/Data/Weapon.xml`, 88);
|
||||
|
||||
const index = {
|
||||
projectDir: PROJECT,
|
||||
sdkDir: "",
|
||||
complete: true,
|
||||
phase: "art",
|
||||
stale: false,
|
||||
assets: new Map([
|
||||
["GameObject", new Map([["athenacannon", [unit]]])],
|
||||
["WeaponTemplate", new Map([["athenacannonweapon", [weapon]]])],
|
||||
]),
|
||||
assetsById: new Map([
|
||||
["athenacannon", [unit]],
|
||||
["athenacannonweapon", [weapon]],
|
||||
]),
|
||||
defines: new Map([["d", [{ name: "D", value: "1", file: unitFile, line: 1, origin: "project" }]]]),
|
||||
files: new Map(),
|
||||
streams: [{ name: "static", entry: `${PROJECT}/Data/Mod.xml`, files: new Set([unitFile.toLowerCase().replace(/\\/g, "/")]) }],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
references: new Map([
|
||||
[`GameObject\u0000athenacannon\u0000${unitFile}\u00002`, [{ file: `${PROJECT}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }]],
|
||||
]),
|
||||
recordsHashes: new Map(),
|
||||
stats: {
|
||||
projectDir: PROJECT, sdkDir: "", phase: "art", complete: true,
|
||||
indexedFiles: 2, parsedFiles: 2, shallowScannedFiles: 0, deferredArtFiles: 0,
|
||||
shallowCacheHits: 0, recordsCacheHits: 0, resolveCacheHits: 0, resolveCalls: 0,
|
||||
snapshotHits: 0, snapshotFallbacks: 0, candidatesMs: 0, walkMs: 0, artScanMs: 0,
|
||||
assetCount: 2, referenceCount: 1, defineCount: 1, manifestFiles: 0,
|
||||
manifestAssetCount: 0, streams: 1, sourceCandidates: 0, elapsedMs: 1,
|
||||
},
|
||||
};
|
||||
|
||||
(async () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const handle = await startLocalServer({
|
||||
getIndex: (projectDir) => (!projectDir || projectDir === PROJECT ? index : null),
|
||||
listProjects: () => [PROJECT, OTHER],
|
||||
loadFile: async () => null,
|
||||
});
|
||||
const endpoint = {
|
||||
instanceId: "smoke-1",
|
||||
url: `http://127.0.0.1:${handle.port}`,
|
||||
token: handle.token,
|
||||
projectDir: PROJECT,
|
||||
projects: [PROJECT, OTHER],
|
||||
processId: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeEndpointForProject(PROJECT, { ...endpoint, projectDir: PROJECT }, home);
|
||||
await writeEndpoint(endpoint, home);
|
||||
await writeInstance(endpoint, home);
|
||||
await writeManifest([endpoint], home);
|
||||
// Signal readiness through a file so the caller never has to parse stdout.
|
||||
writeFileSync(join(home, "READY"), `${handle.port}\n`);
|
||||
// Stay alive until killed.
|
||||
setInterval(() => {}, 1 << 30);
|
||||
})();
|
||||
Reference in New Issue
Block a user