@akira-tl/forgerelay 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ import { loadLocalAgentProfiles, } from "./local-agent-profiles.js";
11
11
  const WORKSPACE_STALE_REMINDER_MS = 2 * 24 * 60 * 60 * 1_000;
12
12
  const WORKSPACE_SESSION_IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
13
13
  const WORKSPACE_GC_INTERVAL_MS = 60 * 60 * 1_000;
14
+ const INITIAL_INSTRUCTION_DISCOVERY_DEPTH = 1;
14
15
  export class WorkspaceRegistry {
15
16
  config;
16
17
  store;
@@ -24,6 +25,9 @@ export class WorkspaceRegistry {
24
25
  this.hooks = new HookRunner(config.hooks, config.logging);
25
26
  this.pruneIdleWorkspaceSessions(new Set(), true);
26
27
  }
28
+ get cachedWorkspaceCount() {
29
+ return this.workspaces.size;
30
+ }
27
31
  async openWorkspace(input, openOptions = {}) {
28
32
  this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
29
33
  const workspaceInput = typeof input === "string" ? { path: input } : input;
@@ -631,8 +635,11 @@ export class WorkspaceRegistry {
631
635
  Object.assign(workspace, this.loadSkillsForWorkspace(workspace.root));
632
636
  workspace.capabilityGuides = loadCapabilityGuides(this.config);
633
637
  workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root);
634
- const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
635
- const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
638
+ workspace.scannedInstructionDirs.clear();
639
+ workspace.knownInstructionPathsByDir.clear();
640
+ workspace.loadedInstructionRealPaths.clear();
641
+ const agentsFiles = await this.loadInitialAgentsFiles(workspace);
642
+ const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace, agentsFiles);
636
643
  const contextFingerprint = bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles);
637
644
  return {
638
645
  workspace,
@@ -686,6 +693,9 @@ export class WorkspaceRegistry {
686
693
  agentProfiles: [],
687
694
  activatedSkillDirs: new Set(),
688
695
  activatedCapabilityGuideDirs: new Set(),
696
+ scannedInstructionDirs: new Set(),
697
+ knownInstructionPathsByDir: new Map(),
698
+ loadedInstructionRealPaths: new Set(),
689
699
  };
690
700
  if (touch)
691
701
  this.store?.touchSession(session.id);
@@ -782,6 +792,9 @@ export class WorkspaceRegistry {
782
792
  agentProfiles: await loadLocalAgentProfiles(this.config, input.root),
783
793
  activatedSkillDirs: new Set(),
784
794
  activatedCapabilityGuideDirs: new Set(),
795
+ scannedInstructionDirs: new Set(),
796
+ knownInstructionPathsByDir: new Map(),
797
+ loadedInstructionRealPaths: new Set(),
785
798
  };
786
799
  this.store?.createSession({
787
800
  id: workspace.id,
@@ -807,8 +820,8 @@ export class WorkspaceRegistry {
807
820
  targetBranch: workspace.worktree?.targetBranch,
808
821
  },
809
822
  });
810
- const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
811
- const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
823
+ const agentsFiles = await this.loadInitialAgentsFiles(workspace);
824
+ const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace, agentsFiles);
812
825
  const contextFingerprint = bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles);
813
826
  return {
814
827
  workspace,
@@ -837,11 +850,9 @@ export class WorkspaceRegistry {
837
850
  }
838
851
  return assertAllowedPath(root, this.config.allowedRoots);
839
852
  }
840
- async loadInitialAgentsFiles(root) {
841
- const resolvedRoot = (await tryRealpath(root)) ?? root;
853
+ async loadInitialAgentsFiles(workspace) {
842
854
  const systemInstructionsPath = resolve(this.config.systemInstructionsPath);
843
855
  const loadedFiles = [];
844
- const loadedRealPaths = new Set();
845
856
  const systemInstructions = await readSystemInstructions(systemInstructionsPath);
846
857
  const systemInstructionsRealPath = await tryRealpath(systemInstructionsPath);
847
858
  if (systemInstructions !== undefined) {
@@ -849,52 +860,125 @@ export class WorkspaceRegistry {
849
860
  path: systemInstructionsPath,
850
861
  content: systemInstructions,
851
862
  });
852
- if (systemInstructionsRealPath)
853
- loadedRealPaths.add(systemInstructionsRealPath);
854
- }
855
- for (const fileName of CONTEXT_FILE_NAMES) {
856
- const path = join(root, fileName);
857
- const content = await readResolvedProjectContextFile(path, resolvedRoot);
858
- if (content === undefined)
859
- continue;
860
- const realPath = await tryRealpath(path);
861
- if (realPath && loadedRealPaths.has(realPath))
862
- continue;
863
- loadedFiles.push({
864
- path,
865
- content,
866
- });
867
- if (realPath)
868
- loadedRealPaths.add(realPath);
863
+ if (systemInstructionsRealPath) {
864
+ workspace.loadedInstructionRealPaths.add(systemInstructionsRealPath);
865
+ }
869
866
  }
867
+ await this.discoverInstructionTree(workspace, workspace.root, INITIAL_INSTRUCTION_DISCOVERY_DEPTH);
868
+ loadedFiles.push(...await this.loadKnownInstructionsInDirectory(workspace, workspace.root));
870
869
  return loadedFiles;
871
870
  }
872
- async findAvailableAgentsFiles(root, loadedFiles) {
871
+ async findAvailableAgentsFiles(workspace, loadedFiles) {
873
872
  const loadedPaths = new Set(loadedFiles.map((file) => resolve(file.path)));
874
- const loadedRealPaths = new Set();
875
- for (const file of loadedFiles) {
876
- const realPath = await tryRealpath(file.path);
877
- if (realPath)
878
- loadedRealPaths.add(realPath);
879
- }
880
873
  const discovered = [];
881
- const agentDir = resolve(this.config.agentDir);
882
- await walkWorkspace(root, async (path, entry) => {
883
- if (isPathInsideRoot(path, agentDir))
884
- return;
885
- if (!entry.isFile())
886
- return;
887
- if (!CONTEXT_FILE_NAMES.has(entry.name))
888
- return;
889
- if (loadedPaths.has(path))
890
- return;
891
- const realPath = await tryRealpath(path);
892
- if (realPath && loadedRealPaths.has(realPath))
893
- return;
894
- discovered.push({ path });
895
- });
874
+ for (const paths of workspace.knownInstructionPathsByDir.values()) {
875
+ for (const path of paths) {
876
+ if (loadedPaths.has(path))
877
+ continue;
878
+ const realPath = await tryRealpath(path);
879
+ if (realPath && workspace.loadedInstructionRealPaths.has(realPath))
880
+ continue;
881
+ discovered.push({ path });
882
+ }
883
+ }
896
884
  return discovered.sort((a, b) => a.path.localeCompare(b.path));
897
885
  }
886
+ async discoverPathInstructions(workspace, inputPath) {
887
+ const absolutePath = resolve(inputPath);
888
+ if (!isPathInsideRoot(absolutePath, workspace.root))
889
+ return [];
890
+ const targetDirectory = dirname(absolutePath);
891
+ const relationship = relative(workspace.root, targetDirectory);
892
+ if (relationship === ".." ||
893
+ relationship.startsWith(`..${sep}`) ||
894
+ resolve(targetDirectory) === resolve(this.config.agentDir) ||
895
+ isPathInsideRoot(targetDirectory, resolve(this.config.agentDir))) {
896
+ return [];
897
+ }
898
+ const directories = [resolve(workspace.root)];
899
+ if (relationship) {
900
+ let current = resolve(workspace.root);
901
+ for (const segment of relationship.split(sep).filter(Boolean)) {
902
+ if (SKIPPED_CONTEXT_DIRS.has(segment))
903
+ break;
904
+ current = join(current, segment);
905
+ directories.push(current);
906
+ }
907
+ }
908
+ const loaded = [];
909
+ for (const directory of directories) {
910
+ await this.discoverInstructionTree(workspace, directory, 0);
911
+ loaded.push(...await this.loadKnownInstructionsInDirectory(workspace, directory));
912
+ }
913
+ return loaded;
914
+ }
915
+ async discoverInstructionTree(workspace, directory, remainingDepth) {
916
+ const resolvedDirectory = resolve(directory);
917
+ if (workspace.scannedInstructionDirs.has(resolvedDirectory))
918
+ return;
919
+ workspace.scannedInstructionDirs.add(resolvedDirectory);
920
+ if (resolvedDirectory !== resolve(workspace.root) &&
921
+ isPathInsideRoot(resolvedDirectory, resolve(this.config.agentDir))) {
922
+ return;
923
+ }
924
+ let entries;
925
+ try {
926
+ entries = await opendir(resolvedDirectory);
927
+ }
928
+ catch {
929
+ return;
930
+ }
931
+ const instructionPaths = [];
932
+ const childDirectories = [];
933
+ for await (const entry of entries) {
934
+ const path = join(resolvedDirectory, entry.name);
935
+ if (entry.isFile() && CONTEXT_FILE_NAMES.has(entry.name)) {
936
+ instructionPaths.push(path);
937
+ continue;
938
+ }
939
+ if (remainingDepth > 0 &&
940
+ entry.isDirectory() &&
941
+ !SKIPPED_CONTEXT_DIRS.has(entry.name)) {
942
+ childDirectories.push(path);
943
+ }
944
+ }
945
+ workspace.knownInstructionPathsByDir.set(resolvedDirectory, instructionPaths.sort((left, right) => left.localeCompare(right)));
946
+ if (remainingDepth <= 0)
947
+ return;
948
+ for (const childDirectory of childDirectories) {
949
+ await this.discoverInstructionTree(workspace, childDirectory, remainingDepth - 1);
950
+ }
951
+ }
952
+ async loadKnownInstructionsInDirectory(workspace, directory) {
953
+ const resolvedDirectory = resolve(directory);
954
+ const paths = workspace.knownInstructionPathsByDir.get(resolvedDirectory) ?? [];
955
+ const loaded = [];
956
+ const resolvedRoot = (await tryRealpath(workspace.root)) ?? resolve(workspace.root);
957
+ const realDirectory = (await tryRealpath(resolvedDirectory)) ?? resolvedDirectory;
958
+ for (const path of paths) {
959
+ const realPath = await tryRealpath(path);
960
+ if (!realPath)
961
+ continue;
962
+ if (!isPathInsideRoot(realPath, resolvedRoot))
963
+ continue;
964
+ if (dirname(realPath) !== realDirectory)
965
+ continue;
966
+ if (workspace.loadedInstructionRealPaths.has(realPath))
967
+ continue;
968
+ let content;
969
+ try {
970
+ content = await readFile(realPath, "utf8");
971
+ }
972
+ catch (error) {
973
+ if (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))
974
+ continue;
975
+ throw error;
976
+ }
977
+ workspace.loadedInstructionRealPaths.add(realPath);
978
+ loaded.push({ path, content });
979
+ }
980
+ return loaded;
981
+ }
898
982
  }
899
983
  function resolveBootstrapContextVisibility(mode, contextAlreadyDelivered) {
900
984
  if (mode === "full")
@@ -998,9 +1082,6 @@ export function formatAgentsPath(path, workspaceRoot) {
998
1082
  }
999
1083
  return relationship.split(sep).join("/");
1000
1084
  }
1001
- function isProjectRootInstructionPath(path, root) {
1002
- return isPathInsideRoot(path, root) && dirname(path) === root;
1003
- }
1004
1085
  async function readSystemInstructions(path) {
1005
1086
  try {
1006
1087
  return await readFile(path, "utf8");
@@ -1012,20 +1093,6 @@ async function readSystemInstructions(path) {
1012
1093
  throw error;
1013
1094
  }
1014
1095
  }
1015
- async function readResolvedProjectContextFile(path, root) {
1016
- try {
1017
- const resolvedPath = await realpath(path);
1018
- if (!isProjectRootInstructionPath(resolvedPath, root))
1019
- return undefined;
1020
- return await readFile(resolvedPath, "utf8");
1021
- }
1022
- catch (error) {
1023
- if (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
1024
- return undefined;
1025
- }
1026
- throw error;
1027
- }
1028
- }
1029
1096
  async function tryRealpath(path) {
1030
1097
  try {
1031
1098
  return await realpath(path);
@@ -1034,25 +1101,6 @@ async function tryRealpath(path) {
1034
1101
  return undefined;
1035
1102
  }
1036
1103
  }
1037
- async function walkWorkspace(directory, visit) {
1038
- let entries;
1039
- try {
1040
- entries = await opendir(directory);
1041
- }
1042
- catch {
1043
- return;
1044
- }
1045
- for await (const entry of entries) {
1046
- const path = join(directory, entry.name);
1047
- if (entry.isDirectory()) {
1048
- if (!SKIPPED_CONTEXT_DIRS.has(entry.name)) {
1049
- await walkWorkspace(path, visit);
1050
- }
1051
- continue;
1052
- }
1053
- await visit(path, entry);
1054
- }
1055
- }
1056
1104
  function isErrnoException(error) {
1057
1105
  return error instanceof Error && "code" in error;
1058
1106
  }
@@ -157,10 +157,16 @@ CLAUDE.md
157
157
  CLAUDE.MD
158
158
  ```
159
159
 
160
- Nested project instruction files are returned as available paths rather than all
161
- being injected eagerly. Read the relevant nested file before working under that path.
162
- `FORGERELAY_AGENT_DIR` is not an instruction source; it remains only a compatibility
163
- skill-discovery path.
160
+ To keep broad workspaces such as `~` fast, initial nested-instruction discovery is
161
+ bounded to direct child directories instead of recursively walking the whole tree.
162
+ Deeper `AGENTS.md` / `CLAUDE.md` files are discovered lazily along a path the first
163
+ time the Agent accesses it, and already-scanned directories are cached for the life
164
+ of that workspace handle. A `read` result carries any newly discovered local
165
+ instructions before the requested file content. Side-effecting file tools and shell
166
+ commands discover instructions before execution; if new local instructions are
167
+ found, ForgeRelay returns them and requires the Agent to retry, so the side effect
168
+ does not occur before the relevant instructions are known. `FORGERELAY_AGENT_DIR`
169
+ is not an instruction source; it remains only a compatibility skill-discovery path.
164
170
 
165
171
  ## MCP capability loading
166
172
 
@@ -151,6 +151,68 @@ snapshot, treat that as stale Host MCP metadata: reconnect/refresh the integrati
151
151
  or use a Host context that reloads `tools/list`. The ForgeRelay process cannot
152
152
  force a Host to invalidate its cached schema.
153
153
 
154
+ ### LSP code intelligence
155
+
156
+ ForgeRelay advertises `code.intelligence` through the Capability Gateway; it does
157
+ not add language-specific top-level MCP tools. ForgeRelay 0.4.0 supports the
158
+ `definition` operation. Language Servers are external dependencies: ForgeRelay may
159
+ discover an executable already installed on the machine, but it never downloads or
160
+ installs one automatically.
161
+
162
+ Effective Language-server definitions resolve in this order:
163
+
164
+ 1. project configuration in `.forgerelay/language-servers.json`;
165
+ 2. global definitions from the `languageServers` object in
166
+ `~/.forgerelay/config.json`;
167
+ 3. built-in discovery for known executables.
168
+
169
+ A project configuration file is an object keyed by definition name. Definitions
170
+ use structured process launch and never go through a shell:
171
+
172
+ ```json
173
+ {
174
+ "typescript": {
175
+ "command": "typescript-language-server",
176
+ "args": ["--stdio"],
177
+ "env": {},
178
+ "languages": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
179
+ "extensions": [".ts", ".tsx", ".js", ".jsx"],
180
+ "languageIdByExtension": {
181
+ ".ts": "typescript",
182
+ ".tsx": "typescriptreact",
183
+ ".js": "javascript",
184
+ ".jsx": "javascriptreact"
185
+ },
186
+ "projectMarkers": ["tsconfig.json", "jsconfig.json"]
187
+ }
188
+ }
189
+ ```
190
+
191
+ Global configuration uses the same definition shape under `languageServers`:
192
+
193
+ ```json
194
+ {
195
+ "languageServers": {
196
+ "typescript": {
197
+ "command": "/absolute/path/to/typescript-language-server",
198
+ "args": ["--stdio"]
199
+ }
200
+ }
201
+ }
202
+ ```
203
+
204
+ Explicit configuration can set `"enabled": false` to suppress the matching
205
+ built-in definition. Project values override global values, and both override
206
+ built-in defaults. ForgeRelay resolves a Language project by walking ancestors of
207
+ the requested source file according to that definition's `projectMarkers`; it does
208
+ not recursively scan the Workspace.
209
+
210
+ Code-intelligence input positions are 1-based line and 1-based Unicode code-point
211
+ column values. The Workspace filesystem is the only v1 document source of truth.
212
+ Definition results may point outside the Workspace and are then marked
213
+ `external: true`; this is informational only and does not expand allowed roots or
214
+ file-tool authority.
215
+
154
216
  `rename` is the canonical move/rename primitive for files and directories; there
155
217
  is no separate `move` MCP tool.
156
218
 
@@ -193,13 +255,24 @@ files. For a managed-worktree-backed workspace, `close_workspace` requires
193
255
  `commitMessage` and runs the existing safe worktree finalize lifecycle: close Hooks,
194
256
  commit when needed, fast-forward-only integration, cleanup, and alias invalidation.
195
257
 
258
+ Hot workspace/session activity timestamps are coalesced in memory and flushed to the
259
+ SQLite state database in a transaction at most every five minutes; normal shutdown
260
+ performs a final explicit flush. Reads within the running ForgeRelay process see the
261
+ latest in-memory timestamps immediately. Workspace creation, close/status changes,
262
+ context-delivery checkpoints, and other semantic state transitions remain immediate
263
+ persistent writes. A hard process crash may therefore lose only the most recent
264
+ activity timestamp window, not the existence or closed/open state of a workspace.
265
+
196
266
  Regular `bash` has no execution-timeout input. `action="run"` (the default) waits
197
267
  in the foreground for at most 300 seconds; if the process is still alive, the
198
268
  result contains `running: true` and a canonical `processId`. Reuse the same `bash`
199
269
  with `action="process"` to poll/wait, send `input`, resize a PTY, or set
200
270
  `interrupt:true`; each wait can be up to 300 seconds. ForgeRelay does not kill a
201
271
  process merely because a wait window expires. Completed background processes are
202
- delivered once with a later tool result for the same logical workspace ID.
272
+ delivered once with a later tool result for the same logical workspace ID. An
273
+ unconsumed completed-process notice is retained for at most five minutes, and
274
+ ForgeRelay also bounds active and completed process counts so repeated background
275
+ commands cannot grow server memory without limit.
203
276
 
204
277
  Codex mode retains `write_stdin` only as an experimental compatibility adapter;
205
278
  regular Agent workflows should use the single `bash` process lifecycle.
@@ -348,8 +421,12 @@ Arrays or empty values are not accepted. Symbolic links are followed, so the
348
421
  runtime entry may point at a canonical source elsewhere on disk.
349
422
 
350
423
  Project-root `AGENTS.md` / `CLAUDE.md` files remain project context and are
351
- loaded separately. `FORGERELAY_AGENT_DIR` does not select a global instruction
352
- file; it remains a compatibility path for Agent Skills.
424
+ loaded separately. Initial nested-instruction discovery checks only direct child
425
+ directories; deeper instruction files are discovered lazily when a workspace path
426
+ is first accessed. Reads surface newly discovered instructions inline, while
427
+ side-effecting file/shell operations stop before execution and require a retry if
428
+ that access discovers new local instructions. `FORGERELAY_AGENT_DIR` does not
429
+ select a global instruction file; it remains a compatibility path for Agent Skills.
353
430
 
354
431
  ## Skills and subagents
355
432
 
package/docs/roadmap.md CHANGED
@@ -183,6 +183,17 @@ capability
183
183
  - inventory 区分持久化 `status` 与派生 `state`:`status="active"` 表示尚未显式关闭,`state` 再区分 active、stale、invalid 与 closed;missing root 或外部删除的 managed worktree 可以保持可诊断的 active record,同时显示为 invalid;
184
184
  - inventory 查看本身不刷新 workspace `lastUsedAt`,支持过滤与分页,并继续让现有 `close_workspace` 承担用户确认后的实际清理/finalize lifecycle。
185
185
 
186
+ ### 0.3.7 — 资源生命周期与 Workspace I/O 性能补丁
187
+
188
+ 0.3.7 处理长时间运行实例在高请求量、高输出 `bash`、大量 logical workspace 与宽根目录下的资源放大问题,不改变 canonical 9-tool surface:
189
+
190
+ - completed background process 的 Agent 可消费状态最多保留 5 分钟;底层 ChildProcess/PTY handle 在退出时立即释放,completed notice 数量与 active process 数量都有硬上限,并缩小单 process 输出驻留预算;
191
+ - 高输出 head/tail buffer 保留 Unicode code-point 语义,但不再通过 `Array.from(整段输出)` 构造巨型临时数组,降低 V8 heap 扩容与 GC 压力;
192
+ - MCP transport registry 与 review checkpoint state 加入容量边界;正常 transport close/workspace close 仍立即释放,异常遗弃对象不能再无限累积;OAuth 过期 authorization code 也会主动淘汰;
193
+ - `open_workspace` 的 instruction discovery 首轮只检查 root 与直接子目录,不再递归整棵 workspace。更深层 `AGENTS.md` / `CLAUDE.md` 在 Agent 首次访问对应路径时沿祖先目录惰性发现,并缓存已扫描目录;read 可直接携带新发现指令,write/edit/rename/delete/bash 等副作用调用则在执行前返回指令并要求重试;
194
+ - Workspace SQLite 继续作为本地持久化真源,不引入 Redis/PostgreSQL/Docker。高频 session/conversation `lastUsedAt` touch 进入内存 write-behind cache,最多每 5 分钟事务批量 flush,normal shutdown 再显式 flush;create/close/status 等语义性状态仍同步持久化;
195
+ - debug runtime telemetry 定期报告 RSS/heap、transport、process、workspace cache 与 review state 数量,为后续真实实例资源趋势提供可观测性。
196
+
186
197
  必要安全语义始终留在 Core tool interface、Capability contract 或自动 Hook report 中;渐进式披露不能成为隐藏权限、隐式 autonomous workflow 或绕过 allowed roots/auth 的机制。`rename` 继续作为文件和目录 move/rename 的统一 primitive。
187
198
 
188
199
  ## 0.4 — LSP code intelligence v1
@@ -214,6 +225,21 @@ Candidate servers include `typescript-language-server`/tsserver, Pyright,
214
225
  `rust-analyzer`, `gopls`, and `clangd`, but ForgeRelay should treat server
215
226
  commands/configuration as external dependencies.
216
227
 
228
+ 0.4 is delivered as independently published patch releases rather than one large
229
+ 0.4.0 batch. Every boundary must complete local acceptance, update release metadata,
230
+ create and push its matching tag, pass cloud CI, publish npm and the GitHub Release,
231
+ and verify publication before work begins on the next boundary:
232
+
233
+ - **0.4.0** — Language-service foundation plus the complete `definition` tracer bullet;
234
+ - **0.4.1** — hover/type information;
235
+ - **0.4.2** — references and bounded semantic-location results;
236
+ - **0.4.3** — hierarchical document symbols and bounded workspace symbols;
237
+ - **0.4.4** — push/pull diagnostics and Diagnostic snapshots;
238
+ - **0.4.5** — cancellation, deadlines, crash recovery/config invalidation, concurrency,
239
+ and full Language-service resource/lifecycle hardening;
240
+ - **0.4.6** — optional real-server interoperability, cross-platform checks, fresh Host
241
+ acceptance, documentation, and final LSP v1 closure.
242
+
217
243
  ## 0.5 — First-class subagent MCP
218
244
 
219
245
  ForgeRelay already owns provider adapters and resumable local agent sessions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -42,7 +42,7 @@
42
42
  "debug:accept": "node scripts/debug/accept.mjs",
43
43
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
44
44
  "start": "node dist/cli.js serve",
45
- "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
45
+ "test": "tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
46
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
47
47
  "release:check": "node scripts/release-version.mjs check",
48
48
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -79,6 +79,8 @@
79
79
  "react": "^19.2.6",
80
80
  "react-dom": "^19.2.6",
81
81
  "semver": "^7.8.4",
82
+ "vscode-jsonrpc": "^9.0.1",
83
+ "vscode-languageserver-protocol": "^3.18.2",
82
84
  "yaml": "^2.9.0",
83
85
  "zod": "^4.4.3"
84
86
  },
@@ -244,6 +244,7 @@ try {
244
244
  "process.lifecycle",
245
245
  "hooks.lifecycle",
246
246
  "capability-guides.read",
247
+ "code.intelligence",
247
248
  ...(process.platform === "linux" ? ["artifact.native-download"] : []),
248
249
  "ui.mcp-app",
249
250
  "review.changes",
@@ -253,6 +254,7 @@ try {
253
254
  assert.deepEqual(capabilityCatalog.map((entry) => entry.name), [
254
255
  "hooks.check",
255
256
  "review.changes",
257
+ "code.intelligence",
256
258
  ...(process.platform === "linux" ? ["artifact.download"] : []),
257
259
  ]);
258
260
  assert.equal(capabilityCatalog[0].available, true);
@@ -323,6 +325,18 @@ try {
323
325
  assert.equal(describedCapability.isError, undefined);
324
326
  assert.equal(describedCapability.structuredContent.capability.guide.name, "lifecycle-hooks");
325
327
  assert.equal(describedCapability.structuredContent.capability.inputSchema.type, "object");
328
+ const describedCodeIntelligence = callTool(oauth.accessToken, sessionId, 88, "capability", {
329
+ workspaceId,
330
+ name: "code.intelligence",
331
+ action: "describe",
332
+ });
333
+ assert.equal(describedCodeIntelligence.isError, undefined);
334
+ assert.equal(describedCodeIntelligence.structuredContent.capability.guide.name, "code-intelligence");
335
+ assert.equal(describedCodeIntelligence.structuredContent.capability.inputSchema.type, "object");
336
+ assert.equal(
337
+ describedCodeIntelligence.structuredContent.capability.inputSchema.properties.operation.const,
338
+ "definition",
339
+ );
326
340
  if (process.platform === "linux") {
327
341
  const describedArtifact = callTool(oauth.accessToken, sessionId, 82, "capability", {
328
342
  workspaceId,
@@ -350,6 +364,7 @@ try {
350
364
  "artifacts-review",
351
365
  "host-integration",
352
366
  "shell-processes",
367
+ "code-intelligence",
353
368
  ]);
354
369
  const hooksGuide = callTool(oauth.accessToken, sessionId, 78, "read", {
355
370
  workspaceId,