@akira-tl/forgerelay 0.8.9 → 0.8.10
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 +20 -0
- package/capabilities/code-intelligence/GUIDE.md +7 -3
- package/capabilities/subagents/GUIDE.md +9 -0
- package/capabilities/workspace/workspace-tasks/GUIDE.md +6 -0
- package/dist/cli/init.js +180 -0
- package/dist/cli/setup-support.js +65 -0
- package/dist/cli.js +9 -94
- package/dist/lsp/code-intelligence.js +17 -3
- package/dist/lsp/runtime/diagnostic-snapshots.js +52 -1
- package/dist/lsp/runtime/managed-language-servers.js +172 -0
- package/dist/lsp/runtime/manager.js +37 -2
- package/dist/lsp/runtime/process-launch.js +3 -0
- package/dist/lsp/test-support/server-fixture.js +5 -2
- package/dist/mcp/process/process-platform.js +21 -9
- package/dist/mcp/process/process-sessions.js +3 -3
- package/dist/mcp/process/tools.js +6 -6
- package/dist/mcp/server/core/capability-registry.js +7 -2
- package/dist/mcp/server/core/tool-support.js +8 -5
- package/dist/mcp/server/operations/runtime/filesystem-tools.js +74 -19
- package/dist/mcp/server/operations/runtime/mutation-diagnostics.js +54 -0
- package/dist/mcp/server/operations/runtime/operation-runtime.js +8 -7
- package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +28 -8
- package/dist/mcp/server/workspace/runtime/workspace-open.js +4 -1
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +3 -3
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/runtime/config/config.js +1 -0
- package/dist/runtime/config/user-config.js +1 -10
- package/dist/server.js +36 -6
- package/dist/workspaces/context.js +35 -0
- package/dist/workspaces/relay/transport/remote-transport.js +30 -3
- package/dist/workspaces/resources/resource-monitor.js +377 -0
- package/dist/workspaces/resources/skills.js +13 -15
- package/dist/workspaces/sessions.js +3 -0
- package/dist/workspaces.js +11 -0
- package/docs/chatgpt-coding-workflow.md +4 -3
- package/docs/configuration.md +14 -9
- package/package.json +2 -3
- package/scripts/release/publish.mjs +12 -9
- package/scripts/release/release-gate.test.mjs +3 -1
- package/scripts/release/release-version.test.mjs +13 -1
- package/scripts/release-parity.mjs +1 -1
- package/scripts/release-proof.mjs +15 -0
- package/scripts/release-proof.test.mjs +27 -1
- package/scripts/release-version.mjs +16 -1
- package/skills/subagent-delegation/SKILL.md +0 -132
|
@@ -4,6 +4,7 @@ import { dirname, join, relative, resolve, sep } from "node:path";
|
|
|
4
4
|
import { markCapabilityGuideActivated, resolveCapabilityGuideReadPath, } from "../mcp/server/core/capabilities.js";
|
|
5
5
|
import { assertAllowedPath, isPathInsideRoot, resolveAllowedPath, } from "../mcp/filesystem/roots.js";
|
|
6
6
|
import { loadWorkspaceSkills, markSkillActivated, resolveSkillReadPath, } from "./resources/skills.js";
|
|
7
|
+
import { WorkspaceResourceMonitor, } from "./resources/resource-monitor.js";
|
|
7
8
|
const INITIAL_INSTRUCTION_DISCOVERY_DEPTH = 1;
|
|
8
9
|
const CONTEXT_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]);
|
|
9
10
|
const SKIPPED_CONTEXT_DIRS = new Set([
|
|
@@ -25,6 +26,7 @@ const SKIPPED_CONTEXT_DIRS = new Set([
|
|
|
25
26
|
*/
|
|
26
27
|
export class WorkspaceContextService {
|
|
27
28
|
config;
|
|
29
|
+
resourceMonitor = new WorkspaceResourceMonitor();
|
|
28
30
|
constructor(config) {
|
|
29
31
|
this.config = config;
|
|
30
32
|
}
|
|
@@ -87,6 +89,7 @@ export class WorkspaceContextService {
|
|
|
87
89
|
markReadPathLoaded(workspace, readPath) {
|
|
88
90
|
if (readPath.skillRead?.isSkillFile) {
|
|
89
91
|
markSkillActivated(workspace.activatedSkillDirs, readPath.skillRead.skill);
|
|
92
|
+
this.resourceMonitor.markSkillActivated(workspace.id, readPath.skillRead.skill.filePath);
|
|
90
93
|
}
|
|
91
94
|
if (readPath.capabilityGuideRead?.isGuideFile) {
|
|
92
95
|
markCapabilityGuideActivated(workspace.activatedCapabilityGuideDirs, readPath.capabilityGuideRead.guide);
|
|
@@ -198,8 +201,40 @@ export class WorkspaceContextService {
|
|
|
198
201
|
await this.discoverInstructionTree(workspace, directory, 0);
|
|
199
202
|
loaded.push(...await this.loadKnownInstructionsInDirectory(workspace, directory));
|
|
200
203
|
}
|
|
204
|
+
for (const file of loaded) {
|
|
205
|
+
this.resourceMonitor.trackLoadedInstruction(workspace.id, workspace.root, file.path);
|
|
206
|
+
}
|
|
201
207
|
return loaded;
|
|
202
208
|
}
|
|
209
|
+
trackWorkspaceResources(workspace, agentsFiles, availableAgentsFiles) {
|
|
210
|
+
this.resourceMonitor.trackWorkspace({
|
|
211
|
+
workspaceId: workspace.id,
|
|
212
|
+
root: workspace.root,
|
|
213
|
+
loadedInstructions: agentsFiles.map((file) => file.path),
|
|
214
|
+
availableInstructions: availableAgentsFiles.map((file) => file.path),
|
|
215
|
+
skills: workspace.skills.map((skill) => ({
|
|
216
|
+
name: skill.name,
|
|
217
|
+
filePath: skill.filePath,
|
|
218
|
+
baseDir: skill.baseDir,
|
|
219
|
+
activated: workspace.activatedSkillDirs.has(resolve(skill.baseDir)),
|
|
220
|
+
})),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
claimResourceUpdates(workspaceId, conversationScopeId) {
|
|
224
|
+
return this.resourceMonitor.claim(workspaceId, conversationScopeId);
|
|
225
|
+
}
|
|
226
|
+
acknowledgeResourceUpdates(workspaceId, conversationScopeId) {
|
|
227
|
+
this.resourceMonitor.acknowledge(workspaceId, conversationScopeId);
|
|
228
|
+
}
|
|
229
|
+
resourceUpdatesCurrent(workspaceId, conversationScopeId) {
|
|
230
|
+
return this.resourceMonitor.isCurrentForScope(workspaceId, conversationScopeId);
|
|
231
|
+
}
|
|
232
|
+
forgetWorkspaceResources(workspaceId) {
|
|
233
|
+
this.resourceMonitor.forgetWorkspace(workspaceId);
|
|
234
|
+
}
|
|
235
|
+
pruneWorkspaceResources(activeWorkspaceIds) {
|
|
236
|
+
this.resourceMonitor.pruneWorkspaces(activeWorkspaceIds);
|
|
237
|
+
}
|
|
203
238
|
async discoverInstructionTree(workspace, directory, remainingDepth) {
|
|
204
239
|
const resolvedDirectory = resolve(directory);
|
|
205
240
|
if (workspace.scannedInstructionDirs.has(resolvedDirectory))
|
|
@@ -27,15 +27,42 @@ export async function readRemoteOwnerToken(sshRoute) {
|
|
|
27
27
|
const result = await runSshCommand([
|
|
28
28
|
...prefix,
|
|
29
29
|
target,
|
|
30
|
-
|
|
31
|
-
"auth",
|
|
32
|
-
"__owner-token",
|
|
30
|
+
remoteOwnerTokenCommand(),
|
|
33
31
|
]);
|
|
34
32
|
const token = result.stdout.trim();
|
|
35
33
|
if (!token)
|
|
36
34
|
throw new Error("SSH owner-token command returned an empty token.");
|
|
37
35
|
return token;
|
|
38
36
|
}
|
|
37
|
+
function remoteOwnerTokenCommand() {
|
|
38
|
+
const script = [
|
|
39
|
+
"set -u",
|
|
40
|
+
"try_forgerelay() {",
|
|
41
|
+
" candidate=\"$1\"",
|
|
42
|
+
" [ -x \"$candidate\" ] || return 1",
|
|
43
|
+
" token=$(\"$candidate\" auth __owner-token 2>/dev/null) || token=",
|
|
44
|
+
" if [ -z \"$token\" ]; then",
|
|
45
|
+
" sibling_node=$(dirname \"$candidate\")/node",
|
|
46
|
+
" [ -x \"$sibling_node\" ] || return 1",
|
|
47
|
+
" token=$(\"$sibling_node\" \"$candidate\" auth __owner-token 2>/dev/null) || return 1",
|
|
48
|
+
" fi",
|
|
49
|
+
" [ -n \"$token\" ] || return 1",
|
|
50
|
+
" printf '%s\\n' \"$token\"",
|
|
51
|
+
" exit 0",
|
|
52
|
+
"}",
|
|
53
|
+
"path_candidate=$(command -v forgerelay 2>/dev/null || true)",
|
|
54
|
+
"[ -n \"$path_candidate\" ] && try_forgerelay \"$path_candidate\"",
|
|
55
|
+
"for candidate in \"$HOME/.local/bin/forgerelay\" \"$HOME/.npm-global/bin/forgerelay\" \"$HOME/.volta/bin/forgerelay\" \"$HOME/.local/share/pnpm/forgerelay\" \"$HOME/.bun/bin/forgerelay\" \"$HOME\"/.nvm/versions/node/*/bin/forgerelay; do",
|
|
56
|
+
" try_forgerelay \"$candidate\"",
|
|
57
|
+
"done",
|
|
58
|
+
"printf '%s\\n' 'ForgeRelay CLI was not found in the non-interactive SSH environment or supported user-local install paths.' >&2",
|
|
59
|
+
"exit 127",
|
|
60
|
+
].join("\n");
|
|
61
|
+
return `sh -c ${shellQuote(script)}`;
|
|
62
|
+
}
|
|
63
|
+
function shellQuote(value) {
|
|
64
|
+
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
|
65
|
+
}
|
|
39
66
|
export async function openRemoteServiceEndpoint(target, sshRoute) {
|
|
40
67
|
if (!sshRoute) {
|
|
41
68
|
return { endpoint: target, close: async () => undefined };
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, unwatchFile, watchFile } from "node:fs";
|
|
2
|
+
import { relative, resolve, sep } from "node:path";
|
|
3
|
+
import { createTwoFilesPatch } from "diff";
|
|
4
|
+
import { skillSummaryFromContent } from "./skills.js";
|
|
5
|
+
const WATCH_INTERVAL_MS = 750;
|
|
6
|
+
const MAX_HISTORY = 128;
|
|
7
|
+
const MAX_DELIVERY_CHARACTERS = 32 * 1024;
|
|
8
|
+
const MAX_PATCH_CHARACTERS = 8 * 1024;
|
|
9
|
+
const sharedWatches = new Map();
|
|
10
|
+
let monitorSequence = 0;
|
|
11
|
+
/**
|
|
12
|
+
* Tracks already-advertised Workspace instruction and Skill files. The OS-level
|
|
13
|
+
* stat watcher is process-global and canonical-path keyed, so two Workspaces or
|
|
14
|
+
* conversations that refer to the same physical file still create only one
|
|
15
|
+
* underlying watcher. Resource changes are delivered as bounded deltas.
|
|
16
|
+
*/
|
|
17
|
+
export class WorkspaceResourceMonitor {
|
|
18
|
+
id = `monitor-${++monitorSequence}`;
|
|
19
|
+
states = new Map();
|
|
20
|
+
trackWorkspace(input) {
|
|
21
|
+
const state = this.states.get(input.workspaceId) ?? {
|
|
22
|
+
root: input.root,
|
|
23
|
+
revision: 0,
|
|
24
|
+
tracked: new Map(),
|
|
25
|
+
subscriptions: new Map(),
|
|
26
|
+
changes: [],
|
|
27
|
+
deliveredRevisionByScope: new Map(),
|
|
28
|
+
};
|
|
29
|
+
state.root = input.root;
|
|
30
|
+
this.states.set(input.workspaceId, state);
|
|
31
|
+
const desired = new Map();
|
|
32
|
+
for (const path of input.availableInstructions) {
|
|
33
|
+
this.addDesired(desired, path, {
|
|
34
|
+
kind: "instruction-available",
|
|
35
|
+
displayPath: displayWorkspacePath(path, input.root),
|
|
36
|
+
exposeSkillBody: false,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
for (const path of input.loadedInstructions) {
|
|
40
|
+
this.addDesired(desired, path, {
|
|
41
|
+
kind: "instruction-loaded",
|
|
42
|
+
displayPath: displayWorkspacePath(path, input.root),
|
|
43
|
+
exposeSkillBody: false,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
for (const skill of input.skills) {
|
|
47
|
+
this.addDesired(desired, skill.filePath, {
|
|
48
|
+
kind: "skill",
|
|
49
|
+
displayPath: `skills://${encodeURIComponent(skill.name)}`,
|
|
50
|
+
skillName: skill.name,
|
|
51
|
+
exposeSkillBody: skill.activated,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const [key, stop] of state.tracked) {
|
|
55
|
+
if (desired.has(key))
|
|
56
|
+
continue;
|
|
57
|
+
stop();
|
|
58
|
+
state.tracked.delete(key);
|
|
59
|
+
state.subscriptions.delete(key);
|
|
60
|
+
}
|
|
61
|
+
for (const [key, item] of desired) {
|
|
62
|
+
state.subscriptions.set(key, item.subscription);
|
|
63
|
+
if (state.tracked.has(key)) {
|
|
64
|
+
synchronizeSharedWatch(key);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const subscriberId = `${this.id}:${input.workspaceId}:${key}`;
|
|
68
|
+
const stop = subscribeSharedWatch(item.path, subscriberId, (oldContent, newContent) => {
|
|
69
|
+
this.recordChange(input.workspaceId, key, oldContent, newContent);
|
|
70
|
+
});
|
|
71
|
+
state.tracked.set(key, stop);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
trackLoadedInstruction(workspaceId, root, path) {
|
|
75
|
+
const state = this.states.get(workspaceId);
|
|
76
|
+
if (!state)
|
|
77
|
+
return;
|
|
78
|
+
const key = canonicalWatchKey(path);
|
|
79
|
+
state.subscriptions.set(key, {
|
|
80
|
+
kind: "instruction-loaded",
|
|
81
|
+
displayPath: displayWorkspacePath(path, root),
|
|
82
|
+
exposeSkillBody: false,
|
|
83
|
+
});
|
|
84
|
+
if (state.tracked.has(key))
|
|
85
|
+
return;
|
|
86
|
+
const subscriberId = `${this.id}:${workspaceId}:${key}`;
|
|
87
|
+
state.tracked.set(key, subscribeSharedWatch(path, subscriberId, (oldContent, newContent) => {
|
|
88
|
+
this.recordChange(workspaceId, key, oldContent, newContent);
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
markSkillActivated(workspaceId, skillPath) {
|
|
92
|
+
const state = this.states.get(workspaceId);
|
|
93
|
+
if (!state)
|
|
94
|
+
return;
|
|
95
|
+
const subscription = state.subscriptions.get(canonicalWatchKey(skillPath));
|
|
96
|
+
if (subscription?.kind === "skill")
|
|
97
|
+
subscription.exposeSkillBody = true;
|
|
98
|
+
}
|
|
99
|
+
acknowledge(workspaceId, conversationScopeId) {
|
|
100
|
+
if (!conversationScopeId)
|
|
101
|
+
return;
|
|
102
|
+
const state = this.states.get(workspaceId);
|
|
103
|
+
if (!state)
|
|
104
|
+
return;
|
|
105
|
+
state.deliveredRevisionByScope.set(conversationScopeId, state.revision);
|
|
106
|
+
this.pruneDeliveredHistory(state);
|
|
107
|
+
}
|
|
108
|
+
claim(workspaceId, conversationScopeId) {
|
|
109
|
+
if (!conversationScopeId)
|
|
110
|
+
return undefined;
|
|
111
|
+
const state = this.states.get(workspaceId);
|
|
112
|
+
if (!state)
|
|
113
|
+
return undefined;
|
|
114
|
+
// File watcher delivery is advisory and can lag behind a tool call. Refresh
|
|
115
|
+
// tracked files synchronously before deciding whether this conversation has
|
|
116
|
+
// new context so rapid consecutive writes cannot be skipped.
|
|
117
|
+
for (const key of state.tracked.keys())
|
|
118
|
+
synchronizeSharedWatch(key);
|
|
119
|
+
const deliveredRevision = state.deliveredRevisionByScope.get(conversationScopeId) ?? state.revision;
|
|
120
|
+
const changes = state.changes.filter((change) => change.revision > deliveredRevision);
|
|
121
|
+
state.deliveredRevisionByScope.set(conversationScopeId, state.revision);
|
|
122
|
+
if (changes.length === 0)
|
|
123
|
+
return undefined;
|
|
124
|
+
const grouped = coalesceChanges(changes);
|
|
125
|
+
const sections = [];
|
|
126
|
+
const coveredComponents = new Set();
|
|
127
|
+
for (const change of grouped) {
|
|
128
|
+
const formatted = formatResourceChange(change);
|
|
129
|
+
if (!formatted)
|
|
130
|
+
continue;
|
|
131
|
+
sections.push(formatted.text);
|
|
132
|
+
for (const component of formatted.coveredComponents)
|
|
133
|
+
coveredComponents.add(component);
|
|
134
|
+
}
|
|
135
|
+
this.pruneDeliveredHistory(state);
|
|
136
|
+
if (sections.length === 0)
|
|
137
|
+
return undefined;
|
|
138
|
+
const header = "Workspace context changed after this Workspace was opened. Apply only these deltas; unchanged instructions and Skill metadata remain active:";
|
|
139
|
+
const joined = [header, ...sections].join("\n\n");
|
|
140
|
+
const text = joined.length <= MAX_DELIVERY_CHARACTERS
|
|
141
|
+
? joined
|
|
142
|
+
: `${joined.slice(0, MAX_DELIVERY_CHARACTERS)}\n\n[Additional Workspace context deltas were truncated; reopen with context=\"auto\" to refresh metadata.]`;
|
|
143
|
+
return {
|
|
144
|
+
text,
|
|
145
|
+
coveredComponents: [...coveredComponents],
|
|
146
|
+
revision: state.revision,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
isCurrentForScope(workspaceId, conversationScopeId) {
|
|
150
|
+
if (!conversationScopeId)
|
|
151
|
+
return false;
|
|
152
|
+
const state = this.states.get(workspaceId);
|
|
153
|
+
if (!state)
|
|
154
|
+
return false;
|
|
155
|
+
return state.deliveredRevisionByScope.get(conversationScopeId) === state.revision;
|
|
156
|
+
}
|
|
157
|
+
forgetWorkspace(workspaceId) {
|
|
158
|
+
const state = this.states.get(workspaceId);
|
|
159
|
+
if (!state)
|
|
160
|
+
return;
|
|
161
|
+
for (const stop of state.tracked.values())
|
|
162
|
+
stop();
|
|
163
|
+
this.states.delete(workspaceId);
|
|
164
|
+
}
|
|
165
|
+
pruneWorkspaces(activeWorkspaceIds) {
|
|
166
|
+
const active = new Set(activeWorkspaceIds);
|
|
167
|
+
for (const workspaceId of this.states.keys()) {
|
|
168
|
+
if (!active.has(workspaceId))
|
|
169
|
+
this.forgetWorkspace(workspaceId);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
get watchedPhysicalFiles() {
|
|
173
|
+
return sharedWatches.size;
|
|
174
|
+
}
|
|
175
|
+
addDesired(desired, path, subscription) {
|
|
176
|
+
const key = canonicalWatchKey(path);
|
|
177
|
+
const current = desired.get(key);
|
|
178
|
+
if (current?.subscription.kind === "instruction-loaded")
|
|
179
|
+
return;
|
|
180
|
+
desired.set(key, { path, subscription });
|
|
181
|
+
}
|
|
182
|
+
recordChange(workspaceId, key, oldContent, newContent) {
|
|
183
|
+
if (oldContent === newContent)
|
|
184
|
+
return;
|
|
185
|
+
const state = this.states.get(workspaceId);
|
|
186
|
+
const subscription = state?.subscriptions.get(key);
|
|
187
|
+
if (!state || !subscription)
|
|
188
|
+
return;
|
|
189
|
+
state.revision += 1;
|
|
190
|
+
state.changes.push({
|
|
191
|
+
...subscription,
|
|
192
|
+
revision: state.revision,
|
|
193
|
+
watchKey: key,
|
|
194
|
+
oldContent,
|
|
195
|
+
newContent,
|
|
196
|
+
});
|
|
197
|
+
if (state.changes.length > MAX_HISTORY) {
|
|
198
|
+
state.changes.splice(0, state.changes.length - MAX_HISTORY);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
pruneDeliveredHistory(state) {
|
|
202
|
+
if (state.changes.length === 0 || state.deliveredRevisionByScope.size === 0)
|
|
203
|
+
return;
|
|
204
|
+
const floor = Math.min(...state.deliveredRevisionByScope.values());
|
|
205
|
+
while (state.changes[0] && state.changes[0].revision <= floor)
|
|
206
|
+
state.changes.shift();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function subscribeSharedWatch(inputPath, subscriberId, subscriber) {
|
|
210
|
+
const key = canonicalWatchKey(inputPath);
|
|
211
|
+
let entry = sharedWatches.get(key);
|
|
212
|
+
if (!entry) {
|
|
213
|
+
const watchPath = resolve(inputPath);
|
|
214
|
+
const listener = () => synchronizeSharedWatch(key);
|
|
215
|
+
entry = {
|
|
216
|
+
key,
|
|
217
|
+
watchPath,
|
|
218
|
+
content: readOptionalFile(watchPath),
|
|
219
|
+
subscribers: new Map(),
|
|
220
|
+
listener,
|
|
221
|
+
};
|
|
222
|
+
sharedWatches.set(key, entry);
|
|
223
|
+
watchFile(watchPath, { interval: WATCH_INTERVAL_MS, persistent: false }, listener);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
synchronizeSharedWatch(key);
|
|
227
|
+
}
|
|
228
|
+
entry.subscribers.set(subscriberId, subscriber);
|
|
229
|
+
return () => {
|
|
230
|
+
const current = sharedWatches.get(key);
|
|
231
|
+
if (!current)
|
|
232
|
+
return;
|
|
233
|
+
current.subscribers.delete(subscriberId);
|
|
234
|
+
if (current.subscribers.size > 0)
|
|
235
|
+
return;
|
|
236
|
+
unwatchFile(current.watchPath, current.listener);
|
|
237
|
+
sharedWatches.delete(key);
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function synchronizeSharedWatch(key) {
|
|
241
|
+
const entry = sharedWatches.get(key);
|
|
242
|
+
if (!entry)
|
|
243
|
+
return;
|
|
244
|
+
const next = readOptionalFile(entry.watchPath);
|
|
245
|
+
if (next === entry.content)
|
|
246
|
+
return;
|
|
247
|
+
const previous = entry.content;
|
|
248
|
+
entry.content = next;
|
|
249
|
+
for (const subscriber of entry.subscribers.values())
|
|
250
|
+
subscriber(previous, next);
|
|
251
|
+
}
|
|
252
|
+
function canonicalWatchKey(path) {
|
|
253
|
+
const absolute = resolve(path);
|
|
254
|
+
try {
|
|
255
|
+
return realpathSync(absolute);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return absolute;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function readOptionalFile(path) {
|
|
262
|
+
try {
|
|
263
|
+
return readFileSync(path, "utf8");
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function displayWorkspacePath(path, workspaceRoot) {
|
|
273
|
+
const relationship = relative(workspaceRoot, path);
|
|
274
|
+
if (!relationship || relationship === ".." || relationship.startsWith(`..${sep}`))
|
|
275
|
+
return resolve(path);
|
|
276
|
+
return relationship.split(sep).join("/");
|
|
277
|
+
}
|
|
278
|
+
function coalesceChanges(changes) {
|
|
279
|
+
const grouped = new Map();
|
|
280
|
+
for (const change of changes) {
|
|
281
|
+
const existing = grouped.get(change.watchKey);
|
|
282
|
+
if (!existing) {
|
|
283
|
+
grouped.set(change.watchKey, { ...change });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
grouped.set(change.watchKey, {
|
|
287
|
+
...change,
|
|
288
|
+
oldContent: existing.oldContent,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return [...grouped.values()].sort((left, right) => left.revision - right.revision);
|
|
292
|
+
}
|
|
293
|
+
function formatResourceChange(change) {
|
|
294
|
+
if (change.oldContent === change.newContent)
|
|
295
|
+
return undefined;
|
|
296
|
+
if (change.kind === "instruction-available") {
|
|
297
|
+
return {
|
|
298
|
+
text: `Nested Workspace instruction changed: ${change.displayPath}. Its body remains lazy; read it before working under that directory.`,
|
|
299
|
+
coveredComponents: [],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (change.kind === "instruction-loaded") {
|
|
303
|
+
return {
|
|
304
|
+
text: [
|
|
305
|
+
`Workspace instruction delta: ${change.displayPath}`,
|
|
306
|
+
boundedPatch(change.displayPath, change.oldContent, change.newContent),
|
|
307
|
+
].join("\n"),
|
|
308
|
+
coveredComponents: ["agentsFiles"],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (change.exposeSkillBody) {
|
|
312
|
+
return {
|
|
313
|
+
text: [
|
|
314
|
+
`Active Skill delta: ${change.displayPath}`,
|
|
315
|
+
boundedPatch(change.displayPath, change.oldContent, change.newContent),
|
|
316
|
+
].join("\n"),
|
|
317
|
+
coveredComponents: skillMetadataChanged(change) ? ["skills"] : [],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
const metadata = formatSkillMetadataDelta(change);
|
|
321
|
+
return {
|
|
322
|
+
text: metadata ?? `Skill content changed: ${change.displayPath}. The Skill body was not injected because it was not active when it changed; reload it before next use.`,
|
|
323
|
+
coveredComponents: metadata ? ["skills"] : [],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function skillMetadataChanged(change) {
|
|
327
|
+
try {
|
|
328
|
+
const oldSummary = change.oldContent === undefined
|
|
329
|
+
? undefined
|
|
330
|
+
: skillSummaryFromContent(change.oldContent, change.watchKey);
|
|
331
|
+
const newSummary = change.newContent === undefined
|
|
332
|
+
? undefined
|
|
333
|
+
: skillSummaryFromContent(change.newContent, change.watchKey);
|
|
334
|
+
return JSON.stringify(oldSummary) !== JSON.stringify(newSummary);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function formatSkillMetadataDelta(change) {
|
|
341
|
+
try {
|
|
342
|
+
const oldSummary = change.oldContent === undefined
|
|
343
|
+
? undefined
|
|
344
|
+
: skillSummaryFromContent(change.oldContent, change.watchKey);
|
|
345
|
+
const newSummary = change.newContent === undefined
|
|
346
|
+
? undefined
|
|
347
|
+
: skillSummaryFromContent(change.newContent, change.watchKey);
|
|
348
|
+
if (JSON.stringify(oldSummary) === JSON.stringify(newSummary))
|
|
349
|
+
return undefined;
|
|
350
|
+
if (!oldSummary) {
|
|
351
|
+
return `Skill metadata added: ${change.displayPath}\n+ name: ${newSummary?.name ?? change.skillName ?? "unknown"}\n+ description: ${newSummary?.description ?? ""}\n+ disable-model-invocation: ${newSummary?.disableModelInvocation === true}`;
|
|
352
|
+
}
|
|
353
|
+
if (!newSummary)
|
|
354
|
+
return `Skill removed: ${change.displayPath}`;
|
|
355
|
+
const lines = [`Skill metadata delta: ${change.displayPath}`];
|
|
356
|
+
for (const field of ["name", "description", "disableModelInvocation"]) {
|
|
357
|
+
if (oldSummary[field] === newSummary[field])
|
|
358
|
+
continue;
|
|
359
|
+
const label = field === "disableModelInvocation" ? "disable-model-invocation" : field;
|
|
360
|
+
lines.push(`- ${label}: ${String(oldSummary[field])}`);
|
|
361
|
+
lines.push(`+ ${label}: ${String(newSummary[field])}`);
|
|
362
|
+
}
|
|
363
|
+
return lines.join("\n");
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return `Skill metadata became unreadable or changed format: ${change.displayPath}. Reload it before next use.`;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function boundedPatch(path, oldContent, newContent) {
|
|
370
|
+
const patch = createTwoFilesPatch(path, path, oldContent ?? "", newContent ?? "", oldContent === undefined ? "missing" : "before", newContent === undefined ? "missing" : "after", { context: 1 });
|
|
371
|
+
const lines = patch.split("\n");
|
|
372
|
+
const hunk = lines.slice(Math.max(0, lines.findIndex((line) => line.startsWith("@@"))));
|
|
373
|
+
const compact = hunk.join("\n").trimEnd();
|
|
374
|
+
if (compact.length <= MAX_PATCH_CHARACTERS)
|
|
375
|
+
return compact;
|
|
376
|
+
return `${compact.slice(0, MAX_PATCH_CHARACTERS)}\n[delta truncated]`;
|
|
377
|
+
}
|
|
@@ -4,7 +4,6 @@ import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
|
4
4
|
import { parse as parseYaml } from "yaml";
|
|
5
5
|
import { markAdvertisedFileSourceActivated, resolveAdvertisedFileReadPath, } from "../../mcp/filesystem/advertised-files.js";
|
|
6
6
|
import { expandHomePath, isPathInsideRoot } from "../../mcp/filesystem/roots.js";
|
|
7
|
-
const SUBAGENT_DELEGATION_NAME = "subagent-delegation";
|
|
8
7
|
const FRONTMATTER_DELIMITER = "---";
|
|
9
8
|
export function effectiveSkillPaths(config, cwd) {
|
|
10
9
|
const defaultPathCandidates = [
|
|
@@ -58,13 +57,7 @@ export function loadWorkspaceSkills(config, cwd) {
|
|
|
58
57
|
}
|
|
59
58
|
if (config.subagents)
|
|
60
59
|
return { skills, diagnostics };
|
|
61
|
-
return {
|
|
62
|
-
skills: skills.filter((skill) => skill.name !== SUBAGENT_DELEGATION_NAME),
|
|
63
|
-
diagnostics: diagnostics.filter((diagnostic) => {
|
|
64
|
-
const collision = diagnostic.collision;
|
|
65
|
-
return !(collision?.resourceType === "skill" && collision.name === SUBAGENT_DELEGATION_NAME);
|
|
66
|
-
}),
|
|
67
|
-
};
|
|
60
|
+
return { skills, diagnostics };
|
|
68
61
|
}
|
|
69
62
|
function discoverSkills(sourcePath, diagnostics) {
|
|
70
63
|
if (!existsSync(sourcePath))
|
|
@@ -158,8 +151,8 @@ function loadSkillFile(filePath, diagnostics) {
|
|
|
158
151
|
return undefined;
|
|
159
152
|
}
|
|
160
153
|
const baseDir = dirname(filePath);
|
|
161
|
-
const
|
|
162
|
-
if (!name) {
|
|
154
|
+
const summary = skillSummaryFromFrontmatter(frontmatter, filePath);
|
|
155
|
+
if (!summary.name) {
|
|
163
156
|
diagnostics.push({
|
|
164
157
|
type: "error",
|
|
165
158
|
message: `Skill ${filePath} has an invalid or empty name.`,
|
|
@@ -167,14 +160,19 @@ function loadSkillFile(filePath, diagnostics) {
|
|
|
167
160
|
});
|
|
168
161
|
return undefined;
|
|
169
162
|
}
|
|
170
|
-
const description = typeof frontmatter.description === "string"
|
|
171
|
-
? frontmatter.description.trim()
|
|
172
|
-
: "";
|
|
173
163
|
return {
|
|
174
|
-
|
|
175
|
-
description,
|
|
164
|
+
...summary,
|
|
176
165
|
filePath: resolve(filePath),
|
|
177
166
|
baseDir: resolve(baseDir),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export function skillSummaryFromContent(content, filePath) {
|
|
170
|
+
return skillSummaryFromFrontmatter(parseSkillFrontmatter(content), filePath);
|
|
171
|
+
}
|
|
172
|
+
function skillSummaryFromFrontmatter(frontmatter, filePath) {
|
|
173
|
+
return {
|
|
174
|
+
name: normalizedSkillName(frontmatter.name, filePath),
|
|
175
|
+
description: typeof frontmatter.description === "string" ? frontmatter.description.trim() : "",
|
|
178
176
|
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
|
|
179
177
|
};
|
|
180
178
|
}
|
|
@@ -149,6 +149,7 @@ export class WorkspaceSessionService {
|
|
|
149
149
|
continue;
|
|
150
150
|
if (session.mode === "worktree" && worktreeAnchors.get(resolve(session.root))?.id === session.id)
|
|
151
151
|
continue;
|
|
152
|
+
this.context.forgetWorkspaceResources(session.id);
|
|
152
153
|
this.workspaces.delete(session.id);
|
|
153
154
|
}
|
|
154
155
|
}
|
|
@@ -280,6 +281,7 @@ export class WorkspaceSessionService {
|
|
|
280
281
|
workspace.loadedInstructionPaths.clear();
|
|
281
282
|
const agentsFiles = await this.context.loadInitialAgentsFiles(workspace);
|
|
282
283
|
const availableAgentsFiles = await this.context.findAvailableAgentsFiles(workspace, agentsFiles);
|
|
284
|
+
this.context.trackWorkspaceResources(workspace, agentsFiles, availableAgentsFiles);
|
|
283
285
|
const { contextFingerprint, componentFingerprints: bootstrapComponentFingerprints, } = bootstrapContextFingerprints(workspace, agentsFiles, availableAgentsFiles);
|
|
284
286
|
return {
|
|
285
287
|
workspace,
|
|
@@ -356,6 +358,7 @@ export class WorkspaceSessionService {
|
|
|
356
358
|
return context;
|
|
357
359
|
}
|
|
358
360
|
catch (error) {
|
|
361
|
+
this.context.forgetWorkspaceResources(session.id);
|
|
359
362
|
this.workspaces.delete(session.id);
|
|
360
363
|
try {
|
|
361
364
|
await discardFreshManagedWorktree({ worktree, config: this.config });
|
package/dist/workspaces.js
CHANGED
|
@@ -37,6 +37,7 @@ export class WorkspaceRegistry {
|
|
|
37
37
|
}
|
|
38
38
|
async openWorkspace(input, openOptions = {}) {
|
|
39
39
|
this.sessions.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
|
|
40
|
+
this.context.pruneWorkspaceResources(this.workspaces.keys());
|
|
40
41
|
const workspaceInput = typeof input === "string" ? { path: input } : input;
|
|
41
42
|
const bootstrapContext = workspaceInput.context ?? "auto";
|
|
42
43
|
if (workspaceInput.workspaceId) {
|
|
@@ -132,6 +133,7 @@ export class WorkspaceRegistry {
|
|
|
132
133
|
this.sessions.deleteConversationBindingsForWorkspace(canonicalWorkspaceId);
|
|
133
134
|
this.store.setSessionStatus(canonicalWorkspaceId, "closed");
|
|
134
135
|
}
|
|
136
|
+
this.context.forgetWorkspaceResources(canonicalWorkspaceId);
|
|
135
137
|
this.workspaces.delete(canonicalWorkspaceId);
|
|
136
138
|
}
|
|
137
139
|
deleteWorkspace(workspaceId) {
|
|
@@ -141,6 +143,7 @@ export class WorkspaceRegistry {
|
|
|
141
143
|
}
|
|
142
144
|
this.sessions.deleteConversationBindingsForWorkspace(session.id);
|
|
143
145
|
this.store?.deleteSession(session.id);
|
|
146
|
+
this.context.forgetWorkspaceResources(session.id);
|
|
144
147
|
this.workspaces.delete(session.id);
|
|
145
148
|
}
|
|
146
149
|
workspaceIdsForPhysicalWorkspace(workspace) {
|
|
@@ -243,6 +246,7 @@ export class WorkspaceRegistry {
|
|
|
243
246
|
for (const aliasedWorkspaceId of aliasedWorkspaceIds) {
|
|
244
247
|
this.sessions.deleteConversationBindingsForWorkspace(aliasedWorkspaceId);
|
|
245
248
|
this.store?.setSessionStatus(aliasedWorkspaceId, "closed");
|
|
249
|
+
this.context.forgetWorkspaceResources(aliasedWorkspaceId);
|
|
246
250
|
this.workspaces.delete(aliasedWorkspaceId);
|
|
247
251
|
}
|
|
248
252
|
return { ...result, hookReports };
|
|
@@ -394,6 +398,7 @@ export class WorkspaceRegistry {
|
|
|
394
398
|
});
|
|
395
399
|
const agentsFiles = await this.context.loadInitialAgentsFiles(workspace);
|
|
396
400
|
const availableAgentsFiles = await this.context.findAvailableAgentsFiles(workspace, agentsFiles);
|
|
401
|
+
this.context.trackWorkspaceResources(workspace, agentsFiles, availableAgentsFiles);
|
|
397
402
|
const { contextFingerprint, componentFingerprints: bootstrapComponentFingerprints, } = bootstrapContextFingerprints(workspace, agentsFiles, availableAgentsFiles);
|
|
398
403
|
return {
|
|
399
404
|
workspace,
|
|
@@ -410,6 +415,12 @@ export class WorkspaceRegistry {
|
|
|
410
415
|
discoverPathInstructions(workspace, inputPath) {
|
|
411
416
|
return this.context.discoverPathInstructions(workspace, inputPath);
|
|
412
417
|
}
|
|
418
|
+
claimResourceUpdates(workspaceId, conversationScopeId) {
|
|
419
|
+
return this.context.claimResourceUpdates(workspaceId, conversationScopeId);
|
|
420
|
+
}
|
|
421
|
+
acknowledgeResourceUpdates(workspaceId, conversationScopeId) {
|
|
422
|
+
this.context.acknowledgeResourceUpdates(workspaceId, conversationScopeId);
|
|
423
|
+
}
|
|
413
424
|
}
|
|
414
425
|
export { formatAgentsPath };
|
|
415
426
|
export { ensureCheckoutWorkspaceRoot } from "./workspaces/paths.js";
|
|
@@ -253,9 +253,10 @@ global config directory plus:
|
|
|
253
253
|
The workspace result exposes only compact profile metadata so the host can
|
|
254
254
|
choose a provider/profile without loading full provider launch details. Read the
|
|
255
255
|
ForgeRelay-owned `subagents` capability guide when delegation is actually needed;
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
user
|
|
256
|
+
ForgeRelay no longer ships or auto-loads the historical bundled `subagent-delegation` Skill.
|
|
257
|
+
Official delegation behavior lives in the `subagents` capability guide/MCP contract;
|
|
258
|
+
existing user-authored or previously seeded Skills remain normal user configuration
|
|
259
|
+
and are not deleted.
|
|
259
260
|
|
|
260
261
|
Host 正常委派通过现有 `capability` Gateway 中的 `subagent.session` 完成,不增加新的 Core MCP tool。支持的生命周期操作包括 `start`、`resume`、`status`、`list`、`stop` 和 `delete`;具体参数与 provider continuation 能力以 `subagents` capability guide 为准。
|
|
261
262
|
|