@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.
- package/CHANGELOG.md +36 -0
- package/capabilities/code-intelligence/GUIDE.md +11 -0
- package/capabilities/shell-processes/GUIDE.md +2 -2
- package/dist/capabilities.js +6 -0
- package/dist/capability-registry.js +20 -0
- package/dist/config.js +1 -0
- package/dist/logger.js +16 -0
- package/dist/lsp/code-intelligence-error.js +8 -0
- package/dist/lsp/code-intelligence.js +550 -0
- package/dist/lsp/language-server-config.js +313 -0
- package/dist/lsp/position-encoding.js +88 -0
- package/dist/mcp-sessions.js +30 -0
- package/dist/oauth-provider.js +9 -0
- package/dist/process-sessions.js +98 -16
- package/dist/review-checkpoints.js +36 -1
- package/dist/server.js +107 -11
- package/dist/workspace-store.js +96 -19
- package/dist/workspaces.js +130 -82
- package/docs/chatgpt-coding-workflow.md +10 -4
- package/docs/configuration.md +80 -3
- package/docs/roadmap.md +26 -0
- package/package.json +4 -2
- package/scripts/debug/accept.mjs +15 -0
package/dist/workspaces.js
CHANGED
|
@@ -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
|
-
|
|
635
|
-
|
|
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
|
|
811
|
-
const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
`
|
|
163
|
-
|
|
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
352
|
-
|
|
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
|
+
"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
|
},
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -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,
|