@akira-tl/forgerelay 0.8.9 → 0.9.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 +35 -0
- package/README.md +13 -10
- 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/schemas.js +23 -0
- 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/git/worktree-recovery.js +189 -0
- package/dist/workspaces/inventory.js +4 -1
- package/dist/workspaces/relay/result-support.js +36 -0
- package/dist/workspaces/relay/transport/remote-transport.js +30 -3
- package/dist/workspaces/relay/workspace-relay.js +4 -1
- 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 +33 -21
- package/docs/roadmap.md +45 -35
- 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
|
@@ -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
|
|
package/docs/configuration.md
CHANGED
|
@@ -224,7 +224,7 @@ use structured process launch and never go through a shell:
|
|
|
224
224
|
}
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
Global configuration uses the same definition shape under `languageServers
|
|
227
|
+
Global configuration uses the same definition shape under `languageServers`. ForgeRelay can also keep optional npm-managed Language Servers under its private config directory. Agent-triggered installation is disabled by default and must be explicitly enabled:
|
|
228
228
|
|
|
229
229
|
```json
|
|
230
230
|
{
|
|
@@ -233,10 +233,13 @@ Global configuration uses the same definition shape under `languageServers`:
|
|
|
233
233
|
"command": "/absolute/path/to/typescript-language-server",
|
|
234
234
|
"args": ["--stdio"]
|
|
235
235
|
}
|
|
236
|
-
}
|
|
236
|
+
},
|
|
237
|
+
"allowAgentLanguageServerInstall": false
|
|
237
238
|
}
|
|
238
239
|
```
|
|
239
240
|
|
|
241
|
+
`forgerelay init` can manage TypeScript/JavaScript and Pyright installations without touching global npm. When `allowAgentLanguageServerInstall` is `true`, an Agent may use `code.intelligence` operations `managed.status` and `managed.install`; successful installs become discoverable by the same running ForgeRelay process on the next semantic request, with no restart required. Rust Analyzer, `gopls`, and `clangd` remain external toolchain/system installations.
|
|
242
|
+
|
|
240
243
|
Explicit configuration can set `"enabled": false` to suppress the matching
|
|
241
244
|
built-in definition. Project values override global values, and both override
|
|
242
245
|
built-in defaults. ForgeRelay resolves a Language project by walking ancestors of
|
|
@@ -246,10 +249,12 @@ not recursively scan the Workspace.
|
|
|
246
249
|
Code-intelligence input positions are 1-based line and 1-based Unicode code-point
|
|
247
250
|
column values. The Workspace filesystem is the only v1 document source of truth.
|
|
248
251
|
|
|
252
|
+
Successful `write`, `edit`, `rename`, and Codex `apply_patch` mutations automatically request Language Server diagnostics for affected code files. Non-empty diagnostics are appended to the same mutation response; missing Language Servers are skipped without turning a successful file mutation into a failure.
|
|
253
|
+
|
|
249
254
|
For contributor/release interoperability checks, run `npm run lsp:interop`. The
|
|
250
|
-
command tests each supported real Language server that is already
|
|
251
|
-
ForgeRelay built-in
|
|
252
|
-
is absent, and never installs external dependencies.
|
|
255
|
+
command tests each supported real Language server that is already discoverable through
|
|
256
|
+
ForgeRelay built-in resolution and stdio LSP, reports a clear skip when an executable
|
|
257
|
+
is absent, and never installs external dependencies itself.
|
|
253
258
|
Definition results may point outside the Workspace and are then marked
|
|
254
259
|
`external: true`; this is informational only and does not expand allowed roots or
|
|
255
260
|
file-tool authority.
|
|
@@ -333,23 +338,30 @@ inventory is paginated (50 records by default, at most 100) and can filter by Wo
|
|
|
333
338
|
ID, persisted status, derived state, mode, canonical root/source root, or stale-only
|
|
334
339
|
state. Reading inventory does not refresh `lastUsedAt`. Persisted `status="active"`
|
|
335
340
|
means the record has not been explicitly closed; the derived `state` distinguishes
|
|
336
|
-
`active`, `stale`, `invalid`, and `closed`.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
+
`active`, `stale`, `invalid`, and `closed`. Active managed-worktree entries also expose
|
|
342
|
+
a bounded `recovery` projection: ForgeRelay observes backing/source availability, the
|
|
343
|
+
recorded managed and target branches, Git worktree registration, and the backing's
|
|
344
|
+
current branch, then classifies the result as `healthy`, `recoverable`, or
|
|
345
|
+
`manual-intervention`. A missing checkout or externally damaged managed worktree can
|
|
346
|
+
therefore remain diagnostically `status="active"` while appearing as `state="invalid"`.
|
|
347
|
+
This projection is observation only: inventory never runs `git worktree prune`, creates
|
|
348
|
+
or removes worktrees/branches, or otherwise repairs Git state. Canonical identity means
|
|
349
|
+
ordinary same-target opens no longer accumulate duplicate inventory rows;
|
|
350
|
+
`action="list"` remains the formal on-demand inventory path.
|
|
341
351
|
|
|
342
352
|
`open_workspace(action="inspect", workspaceId="...")` is the bounded read-only detail
|
|
343
353
|
path for one known Workspace. It uses an explicit allowlist and never opens/resumes the
|
|
344
354
|
target, changes conversation bindings or bootstrap-delivery records, refreshes
|
|
345
355
|
`lastUsedAt`, or grants file/process/Git/Capability authority. Safe projections include
|
|
346
|
-
ordinary/worktree lifecycle metadata,
|
|
347
|
-
alias/execution-location presentation
|
|
348
|
-
summary. Inspection never returns
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
356
|
+
ordinary/worktree lifecycle metadata, managed-worktree recovery observations,
|
|
357
|
+
Composite member availability summaries, Relay alias/execution-location presentation
|
|
358
|
+
metadata, and an already-existing Task List summary. Inspection never returns
|
|
359
|
+
AGENTS/CLAUDE contents, Skills, Capability-guide paths or contents, Subagent
|
|
360
|
+
bodies/sessions, files, Git diffs, process/Activity output, Hook/review artifacts,
|
|
361
|
+
credentials, network/SSH routes, or Task bodies. For Relay Workspaces the Gateway asks
|
|
362
|
+
the Execution ForgeRelay for this same bounded inspection and forwards sanitized
|
|
363
|
+
lifecycle/recovery facts under the Gateway Workspace identity; the Gateway does not
|
|
364
|
+
inspect or mutate the remote Git repository itself.
|
|
353
365
|
|
|
354
366
|
For checkout-backed Workspaces, `close_workspace` defaults to `action="close"`:
|
|
355
367
|
it marks the persistent Workspace closed, removes current conversation bindings, and
|
|
@@ -605,10 +617,10 @@ forgerelay agents run <profile-or-provider-or-id> "<prompt>"
|
|
|
605
617
|
forgerelay agents show <id>
|
|
606
618
|
```
|
|
607
619
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
with that name
|
|
611
|
-
|
|
620
|
+
ForgeRelay no longer ships or seeds the historical bundled `subagent-delegation` Skill.
|
|
621
|
+
The official delegation rules live in the `subagents` capability guide and MCP
|
|
622
|
+
contract. A user-authored Skill with that name is an ordinary Skill discovered
|
|
623
|
+
from the normal Skill paths; ForgeRelay does not reserve, delete, or rewrite it.
|
|
612
624
|
|
|
613
625
|
## Logging
|
|
614
626
|
|