@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +13 -10
  3. package/capabilities/code-intelligence/GUIDE.md +7 -3
  4. package/capabilities/subagents/GUIDE.md +9 -0
  5. package/capabilities/workspace/workspace-tasks/GUIDE.md +6 -0
  6. package/dist/cli/init.js +180 -0
  7. package/dist/cli/setup-support.js +65 -0
  8. package/dist/cli.js +9 -94
  9. package/dist/lsp/code-intelligence.js +17 -3
  10. package/dist/lsp/runtime/diagnostic-snapshots.js +52 -1
  11. package/dist/lsp/runtime/managed-language-servers.js +172 -0
  12. package/dist/lsp/runtime/manager.js +37 -2
  13. package/dist/lsp/runtime/process-launch.js +3 -0
  14. package/dist/lsp/test-support/server-fixture.js +5 -2
  15. package/dist/mcp/process/process-platform.js +21 -9
  16. package/dist/mcp/process/process-sessions.js +3 -3
  17. package/dist/mcp/process/tools.js +6 -6
  18. package/dist/mcp/server/core/capability-registry.js +7 -2
  19. package/dist/mcp/server/core/schemas.js +23 -0
  20. package/dist/mcp/server/core/tool-support.js +8 -5
  21. package/dist/mcp/server/operations/runtime/filesystem-tools.js +74 -19
  22. package/dist/mcp/server/operations/runtime/mutation-diagnostics.js +54 -0
  23. package/dist/mcp/server/operations/runtime/operation-runtime.js +8 -7
  24. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +28 -8
  25. package/dist/mcp/server/workspace/runtime/workspace-open.js +4 -1
  26. package/dist/mcp/server/workspace/runtime/workspace-tools.js +3 -3
  27. package/dist/mcp/server-instructions.js +1 -1
  28. package/dist/runtime/config/config.js +1 -0
  29. package/dist/runtime/config/user-config.js +1 -10
  30. package/dist/server.js +36 -6
  31. package/dist/workspaces/context.js +35 -0
  32. package/dist/workspaces/git/worktree-recovery.js +189 -0
  33. package/dist/workspaces/inventory.js +4 -1
  34. package/dist/workspaces/relay/result-support.js +36 -0
  35. package/dist/workspaces/relay/transport/remote-transport.js +30 -3
  36. package/dist/workspaces/relay/workspace-relay.js +4 -1
  37. package/dist/workspaces/resources/resource-monitor.js +377 -0
  38. package/dist/workspaces/resources/skills.js +13 -15
  39. package/dist/workspaces/sessions.js +3 -0
  40. package/dist/workspaces.js +11 -0
  41. package/docs/chatgpt-coding-workflow.md +4 -3
  42. package/docs/configuration.md +33 -21
  43. package/docs/roadmap.md +45 -35
  44. package/package.json +2 -3
  45. package/scripts/release/publish.mjs +12 -9
  46. package/scripts/release/release-gate.test.mjs +3 -1
  47. package/scripts/release/release-version.test.mjs +13 -1
  48. package/scripts/release-parity.mjs +1 -1
  49. package/scripts/release-proof.mjs +15 -0
  50. package/scripts/release-proof.test.mjs +27 -1
  51. package/scripts/release-version.mjs +16 -1
  52. 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 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
 
@@ -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 on `PATH` through
251
- ForgeRelay built-in discovery and stdio LSP, reports a clear skip when an executable
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`. A missing checkout or externally removed
337
- managed-worktree root can therefore remain diagnostically `status="active"` while
338
- appearing as `state="invalid"`. Canonical identity means ordinary same-target opens no
339
- longer accumulate duplicate inventory rows; `action="list"` remains the formal
340
- on-demand inventory path.
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, Composite member availability summaries, Relay
347
- alias/execution-location presentation metadata, and an already-existing Task List
348
- summary. Inspection never returns AGENTS/CLAUDE contents, Skills, Capability-guide
349
- paths or contents, Subagent bodies/sessions, files, Git diffs, process/Activity output,
350
- Hook/review artifacts, credentials, network/SSH routes, or Task bodies. Relay inspection
351
- reports only that the Gateway route is known; it does not probe or claim the remote
352
- Workspace lifecycle state.
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
- 0.3 no longer auto-discovers or seeds the package's historical bundled
609
- `subagent-delegation` Skill for new setups. An existing or user-authored Skill
610
- with that name remains an ordinary Skill and is still discovered from the normal
611
- Skill paths when subagents are enabled; ForgeRelay does not delete or rewrite it.
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