@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/capabilities/code-intelligence/GUIDE.md +7 -3
  3. package/capabilities/subagents/GUIDE.md +9 -0
  4. package/capabilities/workspace/workspace-tasks/GUIDE.md +6 -0
  5. package/dist/cli/init.js +180 -0
  6. package/dist/cli/setup-support.js +65 -0
  7. package/dist/cli.js +9 -94
  8. package/dist/lsp/code-intelligence.js +17 -3
  9. package/dist/lsp/runtime/diagnostic-snapshots.js +52 -1
  10. package/dist/lsp/runtime/managed-language-servers.js +172 -0
  11. package/dist/lsp/runtime/manager.js +37 -2
  12. package/dist/lsp/runtime/process-launch.js +3 -0
  13. package/dist/lsp/test-support/server-fixture.js +5 -2
  14. package/dist/mcp/process/process-platform.js +21 -9
  15. package/dist/mcp/process/process-sessions.js +3 -3
  16. package/dist/mcp/process/tools.js +6 -6
  17. package/dist/mcp/server/core/capability-registry.js +7 -2
  18. package/dist/mcp/server/core/tool-support.js +8 -5
  19. package/dist/mcp/server/operations/runtime/filesystem-tools.js +74 -19
  20. package/dist/mcp/server/operations/runtime/mutation-diagnostics.js +54 -0
  21. package/dist/mcp/server/operations/runtime/operation-runtime.js +8 -7
  22. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +28 -8
  23. package/dist/mcp/server/workspace/runtime/workspace-open.js +4 -1
  24. package/dist/mcp/server/workspace/runtime/workspace-tools.js +3 -3
  25. package/dist/mcp/server-instructions.js +1 -1
  26. package/dist/runtime/config/config.js +1 -0
  27. package/dist/runtime/config/user-config.js +1 -10
  28. package/dist/server.js +36 -6
  29. package/dist/workspaces/context.js +35 -0
  30. package/dist/workspaces/relay/transport/remote-transport.js +30 -3
  31. package/dist/workspaces/resources/resource-monitor.js +377 -0
  32. package/dist/workspaces/resources/skills.js +13 -15
  33. package/dist/workspaces/sessions.js +3 -0
  34. package/dist/workspaces.js +11 -0
  35. package/docs/chatgpt-coding-workflow.md +4 -3
  36. package/docs/configuration.md +14 -9
  37. package/package.json +2 -3
  38. package/scripts/release/publish.mjs +12 -9
  39. package/scripts/release/release-gate.test.mjs +3 -1
  40. package/scripts/release/release-version.test.mjs +13 -1
  41. package/scripts/release-parity.mjs +1 -1
  42. package/scripts/release-proof.mjs +15 -0
  43. package/scripts/release-proof.test.mjs +27 -1
  44. package/scripts/release-version.mjs +16 -1
  45. 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
- "forgerelay",
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 name = normalizedSkillName(frontmatter.name, filePath);
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
- name,
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 });
@@ -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
- 0.3 no longer auto-loads the historical bundled `subagent-delegation` Skill for
257
- new setups. Existing user-authored or previously seeded Skills remain normal
258
- user configuration and are not deleted.
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