@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
@@ -4,6 +4,7 @@ export class DiagnosticSnapshotStore {
4
4
  maxDiagnosticsPerDocument;
5
5
  pushSnapshots = new Map();
6
6
  pullSnapshots = new Map();
7
+ pushWaiters = new Map();
7
8
  pushObserved = false;
8
9
  constructor(maxDocuments, maxDiagnosticsPerDocument) {
9
10
  this.maxDocuments = maxDocuments;
@@ -11,10 +12,13 @@ export class DiagnosticSnapshotStore {
11
12
  }
12
13
  capturePush(params, document, encoding) {
13
14
  this.pushObserved = true;
14
- if (!document)
15
+ if (!document) {
16
+ this.notifyPushWaiters(params.uri, true);
15
17
  return;
18
+ }
16
19
  const snapshot = this.normalizeSnapshot(params.diagnostics, document, encoding, params.version === undefined ? {} : { publishedVersion: params.version });
17
20
  this.setBounded(this.pushSnapshots, params.uri, snapshot);
21
+ this.notifyPushWaiters(params.uri, true);
18
22
  }
19
23
  capturePull(diagnostics, document, encoding, resultId) {
20
24
  const snapshot = this.normalizeSnapshot(diagnostics, document, encoding, resultId === undefined ? {} : { resultId });
@@ -41,12 +45,52 @@ export class DiagnosticSnapshotStore {
41
45
  readPull(document, limit) {
42
46
  return this.read(this.pullSnapshots, document, limit, false);
43
47
  }
48
+ async waitForFreshPush(document, limit, timeoutMs, signal) {
49
+ let snapshot = this.readPush(document, limit);
50
+ const deadline = Date.now() + timeoutMs;
51
+ while (snapshot.freshness.state === "missing" || snapshot.freshness.state === "stale") {
52
+ if (signal?.aborted)
53
+ break;
54
+ const remaining = deadline - Date.now();
55
+ if (remaining <= 0 || !await this.waitForPush(document.uri, remaining, signal))
56
+ break;
57
+ snapshot = this.readPush(document, limit);
58
+ }
59
+ return snapshot;
60
+ }
44
61
  hasObservedPushDiagnostics() {
45
62
  return this.pushObserved;
46
63
  }
64
+ waitForPush(uri, timeoutMs, signal) {
65
+ if (signal?.aborted)
66
+ return Promise.resolve(false);
67
+ return new Promise((resolve) => {
68
+ let settled = false;
69
+ const waiters = this.pushWaiters.get(uri) ?? new Set();
70
+ const finish = (observed) => {
71
+ if (settled)
72
+ return;
73
+ settled = true;
74
+ clearTimeout(timer);
75
+ signal?.removeEventListener("abort", onAbort);
76
+ waiters.delete(finish);
77
+ if (waiters.size === 0)
78
+ this.pushWaiters.delete(uri);
79
+ resolve(observed);
80
+ };
81
+ const onAbort = () => finish(false);
82
+ const timer = setTimeout(() => finish(false), timeoutMs);
83
+ timer.unref();
84
+ signal?.addEventListener("abort", onAbort, { once: true });
85
+ waiters.add(finish);
86
+ this.pushWaiters.set(uri, waiters);
87
+ });
88
+ }
47
89
  clear() {
48
90
  this.pushSnapshots.clear();
49
91
  this.pullSnapshots.clear();
92
+ for (const uri of [...this.pushWaiters.keys()])
93
+ this.notifyPushWaiters(uri, false);
50
94
  this.pushObserved = false;
51
95
  }
52
96
  get size() {
@@ -70,6 +114,13 @@ export class DiagnosticSnapshotStore {
70
114
  ...metadata,
71
115
  };
72
116
  }
117
+ notifyPushWaiters(uri, observed) {
118
+ const waiters = this.pushWaiters.get(uri);
119
+ if (!waiters)
120
+ return;
121
+ for (const waiter of [...waiters])
122
+ waiter(observed);
123
+ }
73
124
  setBounded(snapshots, uri, snapshot) {
74
125
  snapshots.delete(uri);
75
126
  snapshots.set(uri, snapshot);
@@ -0,0 +1,172 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { delimiter, join, resolve } from "node:path";
4
+ const MANAGED_LANGUAGE_SERVERS = {
5
+ typescript: {
6
+ id: "typescript",
7
+ label: "TypeScript / JavaScript",
8
+ executable: "typescript-language-server",
9
+ packages: ["typescript-language-server@6", "typescript@6"],
10
+ },
11
+ pyright: {
12
+ id: "pyright",
13
+ label: "Python (Pyright)",
14
+ executable: "pyright-langserver",
15
+ packages: ["pyright@1"],
16
+ },
17
+ };
18
+ export function managedLanguageServerRoot(configDir) {
19
+ return join(configDir, "language-servers");
20
+ }
21
+ export function managedLanguageServerBinDir(configDir) {
22
+ return join(managedLanguageServerRoot(configDir), "node_modules", ".bin");
23
+ }
24
+ export function managedLanguageServerExecutablePath(configDir, id, platform = process.platform) {
25
+ const bin = managedLanguageServerBinDir(configDir);
26
+ const executable = MANAGED_LANGUAGE_SERVERS[id].executable;
27
+ const extensions = platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
28
+ return extensions
29
+ .map((extension) => join(bin, `${executable}${extension}`))
30
+ .find((path) => existsSync(path));
31
+ }
32
+ export function managedLanguageServerIdForCommand(configDir, command, platform = process.platform) {
33
+ return supportedManagedLanguageServers().find((id) => {
34
+ const executable = managedLanguageServerExecutablePath(configDir, id, platform);
35
+ if (executable === undefined)
36
+ return false;
37
+ const executablePath = resolve(executable);
38
+ const commandPath = resolve(command);
39
+ return platform === "win32"
40
+ ? executablePath.toLowerCase() === commandPath.toLowerCase()
41
+ : executablePath === commandPath;
42
+ });
43
+ }
44
+ export function managedTypeScriptTsserverPath(configDir) {
45
+ const path = join(managedLanguageServerRoot(configDir), "node_modules", "typescript", "lib", "tsserver.js");
46
+ return existsSync(path) ? path : undefined;
47
+ }
48
+ export function managedLanguageServerRuntimeIdentity(configDir, id) {
49
+ const packageNames = id === "typescript"
50
+ ? ["typescript-language-server", "typescript"]
51
+ : ["pyright"];
52
+ try {
53
+ const versions = packageNames.map((name) => {
54
+ const packageJson = JSON.parse(readFileSync(join(managedLanguageServerRoot(configDir), "node_modules", name, "package.json"), "utf8"));
55
+ if (!packageJson.version)
56
+ throw new Error(`Missing version for ${name}`);
57
+ return [name, packageJson.version];
58
+ });
59
+ return JSON.stringify(versions);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ export function withManagedLanguageServerPath(env, configDir) {
66
+ if (!configDir)
67
+ return { ...env };
68
+ const bin = managedLanguageServerBinDir(configDir);
69
+ return {
70
+ ...env,
71
+ PATH: [bin, env.PATH].filter((value) => Boolean(value)).join(delimiter),
72
+ };
73
+ }
74
+ export function managedLanguageServerOptions() {
75
+ return [
76
+ {
77
+ value: "typescript",
78
+ label: MANAGED_LANGUAGE_SERVERS.typescript.label,
79
+ hint: "Installs typescript-language-server and TypeScript into ForgeRelay's private config directory.",
80
+ },
81
+ {
82
+ value: "pyright",
83
+ label: MANAGED_LANGUAGE_SERVERS.pyright.label,
84
+ hint: "Installs Pyright into ForgeRelay's private config directory.",
85
+ },
86
+ ];
87
+ }
88
+ export function supportedManagedLanguageServers() {
89
+ return Object.keys(MANAGED_LANGUAGE_SERVERS);
90
+ }
91
+ export function installedManagedLanguageServers(configDir) {
92
+ return supportedManagedLanguageServers()
93
+ .filter((id) => managedExecutableExists(configDir, MANAGED_LANGUAGE_SERVERS[id].executable));
94
+ }
95
+ export function managedLanguageServerPackages(ids) {
96
+ const packages = new Set();
97
+ for (const id of ids) {
98
+ for (const packageSpec of MANAGED_LANGUAGE_SERVERS[id].packages)
99
+ packages.add(packageSpec);
100
+ }
101
+ return [...packages];
102
+ }
103
+ export async function installManagedLanguageServers(ids, configDir, runNpm = defaultNpmRunner) {
104
+ const selected = Array.from(new Set(ids));
105
+ const root = managedLanguageServerRoot(configDir);
106
+ const packages = managedLanguageServerPackages(selected);
107
+ if (packages.length === 0)
108
+ return { installed: [], packages: [], root };
109
+ mkdirSync(root, { recursive: true });
110
+ const packageJsonPath = join(root, "package.json");
111
+ if (!existsSync(packageJsonPath)) {
112
+ writeFileSync(packageJsonPath, JSON.stringify({
113
+ name: "forgerelay-managed-language-servers",
114
+ private: true,
115
+ description: "ForgeRelay-managed optional Language Servers. Do not publish.",
116
+ }, null, 2) + "\n");
117
+ }
118
+ await runNpm([
119
+ "install",
120
+ "--prefix",
121
+ root,
122
+ "--save-exact",
123
+ "--no-audit",
124
+ "--no-fund",
125
+ ...packages,
126
+ ]);
127
+ for (const id of selected)
128
+ assertManagedLanguageServerInstall(configDir, id);
129
+ return { installed: selected, packages, root };
130
+ }
131
+ function assertManagedLanguageServerInstall(configDir, id) {
132
+ if (!managedLanguageServerExecutablePath(configDir, id)) {
133
+ throw new Error(`Managed Language Server ${id} installed without its expected executable.`);
134
+ }
135
+ if (id === "typescript" && !managedTypeScriptTsserverPath(configDir)) {
136
+ throw new Error("Managed TypeScript Language Server installed without a compatible TypeScript tsserver.js. " +
137
+ "ForgeRelay requires a tsserver-based TypeScript package for typescript-language-server.");
138
+ }
139
+ }
140
+ function managedExecutableExists(configDir, executable) {
141
+ const bin = managedLanguageServerBinDir(configDir);
142
+ if (process.platform === "win32") {
143
+ return [".cmd", ".exe", ".bat", ""].some((extension) => existsSync(join(bin, `${executable}${extension}`)));
144
+ }
145
+ return existsSync(join(bin, executable));
146
+ }
147
+ function defaultNpmRunner(args) {
148
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
149
+ return new Promise((resolve, reject) => {
150
+ const child = spawn(command, args, {
151
+ env: process.env,
152
+ stdio: ["ignore", "pipe", "pipe"],
153
+ windowsHide: true,
154
+ shell: process.platform === "win32",
155
+ });
156
+ let stdout = "";
157
+ let stderr = "";
158
+ child.stdout?.setEncoding("utf8");
159
+ child.stderr?.setEncoding("utf8");
160
+ child.stdout?.on("data", (chunk) => { stdout += chunk; });
161
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
162
+ child.once("error", reject);
163
+ child.once("close", (code) => {
164
+ if (code === 0) {
165
+ resolve();
166
+ return;
167
+ }
168
+ const detail = (stderr || stdout).trim();
169
+ reject(new Error(`npm install for managed Language Servers failed with exit ${code ?? "unknown"}${detail ? `: ${detail}` : ""}`));
170
+ });
171
+ });
172
+ }
@@ -1,7 +1,9 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { realpath } from "node:fs/promises";
2
3
  import { resolve, sep } from "node:path";
3
4
  import { CodeIntelligenceError, LanguageService, languageServiceKey, } from "../code-intelligence.js";
4
5
  import { LanguageServerConfigurationError, resolveLanguageProject, } from "../language-server-config.js";
6
+ import { managedLanguageServerIdForCommand, managedLanguageServerRuntimeIdentity, managedTypeScriptTsserverPath, withManagedLanguageServerPath, } from "./managed-language-servers.js";
5
7
  const LANGUAGE_SERVICE_IDLE_MS = 10 * 60 * 1_000;
6
8
  const LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS = 60 * 1_000;
7
9
  const MAX_LANGUAGE_SERVICES = 16;
@@ -50,11 +52,12 @@ export class CodeIntelligenceManager {
50
52
  try {
51
53
  canonicalWorkspaceRoot = await realpath(resolve(workspaceRoot));
52
54
  this.assertWorkspaceRootAvailable(canonicalWorkspaceRoot);
53
- project = await resolveLanguageProject({
55
+ project = withManagedLanguageServerRuntime(await resolveLanguageProject({
54
56
  workspaceRoot: canonicalWorkspaceRoot,
55
57
  sourcePath: input.path,
56
58
  globalConfig: this.config.languageServers,
57
- });
59
+ env: withManagedLanguageServerPath(process.env, this.config.configDir),
60
+ }), this.config.configDir);
58
61
  }
59
62
  catch (error) {
60
63
  if (error instanceof LanguageServerConfigurationError) {
@@ -319,6 +322,38 @@ export class CodeIntelligenceManager {
319
322
  await candidate[1].shutdown();
320
323
  }
321
324
  }
325
+ function withManagedLanguageServerRuntime(project, configDir) {
326
+ if (!configDir)
327
+ return project;
328
+ const managedId = managedLanguageServerIdForCommand(configDir, project.definition.command);
329
+ if (!managedId)
330
+ return project;
331
+ const runtimeIdentity = managedLanguageServerRuntimeIdentity(configDir, managedId);
332
+ if (!runtimeIdentity)
333
+ return project;
334
+ let initializationOptions = project.definition.initializationOptions;
335
+ if (managedId === "typescript") {
336
+ const tsserverPath = managedTypeScriptTsserverPath(configDir);
337
+ if (!tsserverPath)
338
+ return project;
339
+ initializationOptions = { tsserver: { path: tsserverPath } };
340
+ }
341
+ const fingerprint = createHash("sha256")
342
+ .update(project.definition.fingerprint)
343
+ .update("\0")
344
+ .update(runtimeIdentity)
345
+ .update("\0")
346
+ .update(JSON.stringify(initializationOptions ?? null))
347
+ .digest("hex");
348
+ return {
349
+ ...project,
350
+ definition: {
351
+ ...project.definition,
352
+ ...(initializationOptions === undefined ? {} : { initializationOptions }),
353
+ fingerprint,
354
+ },
355
+ };
356
+ }
322
357
  function identityBelongsToWorkspaceRoot(identity, workspaceRoot) {
323
358
  try {
324
359
  const parsed = JSON.parse(identity);
@@ -0,0 +1,3 @@
1
+ export function languageServerCommandRequiresShell(command, platform = process.platform) {
2
+ return platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
3
+ }
@@ -35,6 +35,7 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
35
35
  FORGERELAY_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough",
36
36
  PORT: "1",
37
37
  });
38
+ config.allowAgentLanguageServerInstall = options.allowAgentLanguageServerInstall === true;
38
39
  const store = new SqliteWorkspaceStore(stateDir);
39
40
  const workspaces = new WorkspaceRegistry(config, store);
40
41
  const auditStore = new ActivityAuditStore(stateDir);
@@ -46,7 +47,9 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
46
47
  turnIdForConversation: (conversationScopeId, workspaceId) => activityQueries.currentTurnId(conversationScopeId, workspaceId),
47
48
  });
48
49
  const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
49
- const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore, activityQueries);
50
+ const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore, activityQueries, options.managedLanguageServerInstaller
51
+ ? { managedLanguageServerInstaller: options.managedLanguageServerInstaller }
52
+ : {});
50
53
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
51
54
  const client = new Client({ name: "forgerelay-code-intelligence-test-client", version: "1.0.0" });
52
55
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
@@ -73,7 +76,7 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
73
76
  retryDelay: 100,
74
77
  });
75
78
  });
76
- return { client, project, codeIntelligence, close };
79
+ return { client, project, config, codeIntelligence, close };
77
80
  }
78
81
  export async function callOpen(client, path, conversationScopeId) {
79
82
  return client.callTool({
@@ -11,23 +11,35 @@ const defaultProcessTreeRuntime = {
11
11
  return !result.error && result.status === 0;
12
12
  },
13
13
  };
14
- const LOGIN_SHELLS = new Set(["bash", "ksh", "zsh"]);
15
- const POSIX_SHELLS = new Set(["ash", "dash", "sh"]);
14
+ const POSIX_SHELLS = new Set(["ash", "dash", "ksh", "sh"]);
16
15
  export function resolveShellCommand(command, platform = process.platform, environment = process.env) {
17
16
  if (platform === "win32") {
18
17
  return {
19
18
  executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe",
20
- args: ["/d", "/s", "/c", command],
19
+ // Match Node's native `spawn(command, { shell: cmd.exe })` quoting.
20
+ // cmd.exe /S applies special quote stripping, so the whole command must
21
+ // be wrapped even when the executable inside it is already quoted.
22
+ args: ["/d", "/s", "/c", `"${command}"`],
21
23
  windowsVerbatimArguments: true,
22
24
  };
23
25
  }
24
- const configuredShell = environment.SHELL;
25
- const shellName = configuredShell ? basename(configuredShell) : "";
26
- if (configuredShell && LOGIN_SHELLS.has(shellName)) {
27
- return { executable: configuredShell, args: ["-lc", command] };
26
+ // Agent and Hook commands must not source the user's interactive/login shell
27
+ // configuration. ForgeRelay already inherits PATH and other environment from
28
+ // the process that launched the server; re-running zsh/bash as a login shell
29
+ // can inject prompts, banners, aliases, plugins, or other user-only behavior.
30
+ const configuredShell = environment.FORGERELAY_COMMAND_SHELL?.trim();
31
+ const executable = configuredShell || (platform === "linux" || platform === "darwin"
32
+ ? "/bin/bash"
33
+ : "/bin/sh");
34
+ const shellName = basename(executable);
35
+ if (shellName === "bash") {
36
+ return { executable, args: ["--noprofile", "--norc", "-c", command] };
28
37
  }
29
- if (configuredShell && POSIX_SHELLS.has(shellName)) {
30
- return { executable: configuredShell, args: ["-c", command] };
38
+ if (shellName === "zsh") {
39
+ return { executable, args: ["-f", "-c", command] };
40
+ }
41
+ if (POSIX_SHELLS.has(shellName)) {
42
+ return { executable, args: ["-c", command] };
31
43
  }
32
44
  return { executable: "/bin/sh", args: ["-c", command] };
33
45
  }
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { resolveShellCommand, terminateProcessTree } from "./process-platform.js";
3
3
  const DEFAULT_EXEC_YIELD_MS = 10_000;
4
4
  const DEFAULT_INTERACTIVE_YIELD_MS = 250;
5
- const DEFAULT_POLL_YIELD_MS = 5_000;
5
+ export const DEFAULT_POLL_YIELD_MS = 60_000;
6
6
  const MAX_START_YIELD_MS = 300_000;
7
7
  const MAX_COMMAND_YIELD_MS = 300_000;
8
8
  const MAX_POLL_YIELD_MS = 300_000;
@@ -437,7 +437,7 @@ export class ProcessManager {
437
437
  startPipe(processEntry, input) {
438
438
  const shell = resolveShellCommand(input.command);
439
439
  const detached = process.platform !== "win32";
440
- const child = spawn(input.command, {
440
+ const child = spawn(shell.executable, shell.args, {
441
441
  cwd: input.cwd,
442
442
  env: processEnvironment({
443
443
  workspaceId: input.workspaceId,
@@ -446,8 +446,8 @@ export class ProcessManager {
446
446
  }),
447
447
  stdio: "pipe",
448
448
  windowsHide: true,
449
+ windowsVerbatimArguments: shell.windowsVerbatimArguments,
449
450
  detached,
450
- shell: shell.executable,
451
451
  });
452
452
  processEntry.process = {
453
453
  write: (data) => child.stdin.write(data),
@@ -40,7 +40,7 @@ function registerBashTool(options) {
40
40
  columns: z.number().int().min(1).max(1_000).optional().describe("Initial PTY width for action=run, or resize width for action=process."),
41
41
  rows: z.number().int().min(1).max(1_000).optional().describe("Initial PTY height for action=run, or resize height for action=process."),
42
42
  workingDirectory: z.string().optional().describe("For action=run, working directory relative to the workspace root. Defaults to the workspace root."),
43
- yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("Maximum feedback wait, not a minimum delay: if the process finishes sooner, the call returns immediately. For long-running commands or wait-only action=process calls, set a long window near the Host request deadline (60000ms when supported) instead of repeated short polling. For action=run, use 0 for immediate background handoff; otherwise defaults to 10000ms. For action=process, wait-only calls default to 5000ms and interaction to 250ms."),
43
+ yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("Maximum feedback wait, not a minimum delay: if the process finishes sooner, the call returns immediately. For long-running commands or wait-only action=process calls, set a long window near the Host request deadline (60000ms when supported) instead of repeated short polling. For action=run, use 0 for immediate background handoff; otherwise defaults to 10000ms. For action=process, wait-only calls default to 60000ms and interaction to 250ms."),
44
44
  timeoutMs: z.number().int().min(1).max(86_400_000).optional().describe("For action=run, total execution timeout from process start. On expiry ForgeRelay terminates the process. Omit for no ForgeRelay execution deadline."),
45
45
  maxOutputTokens: z.number().int().positive().max(100_000).optional().describe("Approximate output token budget. Defaults to 10000."),
46
46
  },
@@ -70,7 +70,7 @@ function registerBashTool(options) {
70
70
  ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
71
71
  }, routing.hostScopeIdFor(extra._meta, extra.sessionId));
72
72
  return action === "run"
73
- ? routing.presentSemantic(response, target)
73
+ ? routing.presentSemantic(response, target, routing.hostScopeIdFor(extra._meta, extra.sessionId))
74
74
  : routing.present(response, target);
75
75
  }
76
76
  const workspace = workspaces.getWorkspace(executionWorkspaceId);
@@ -91,7 +91,7 @@ function registerBashTool(options) {
91
91
  yieldTimeMs,
92
92
  timeoutMs,
93
93
  maxOutputTokens,
94
- }, executionContext), target);
94
+ }, executionContext), target, routing.hostScopeIdFor(extra._meta, extra.sessionId));
95
95
  }
96
96
  if (action === "output") {
97
97
  if (!outputId)
@@ -202,7 +202,7 @@ function registerCodexProcessTools(options) {
202
202
  ...(yieldTimeMs !== undefined ? { yieldTimeMs } : {}),
203
203
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
204
204
  ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
205
- }, routing.hostScopeIdFor(extra._meta, extra.sessionId)), target);
205
+ }, routing.hostScopeIdFor(extra._meta, extra.sessionId)), target, routing.hostScopeIdFor(extra._meta, extra.sessionId));
206
206
  }
207
207
  return routing.presentSemantic(await shellRun({
208
208
  workspaceId: target.executionWorkspaceId,
@@ -215,7 +215,7 @@ function registerCodexProcessTools(options) {
215
215
  yieldTimeMs,
216
216
  timeoutMs,
217
217
  maxOutputTokens,
218
- }, context), target);
218
+ }, context), target, routing.hostScopeIdFor(extra._meta, extra.sessionId));
219
219
  });
220
220
  registerAppTool(server, "write_stdin", {
221
221
  title: "Write to process",
@@ -229,7 +229,7 @@ function registerCodexProcessTools(options) {
229
229
  chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."),
230
230
  columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."),
231
231
  rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."),
232
- yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("Milliseconds to keep waiting before returning again, max 300000. Polling defaults to 5000; interaction defaults to 250."),
232
+ yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("Milliseconds to keep waiting before returning again, max 300000. Polling defaults to 60000; interaction defaults to 250."),
233
233
  maxOutputTokens: z.number().int().positive().max(100_000).optional().describe("Approximate output token budget. Defaults to 10000."),
234
234
  },
235
235
  outputSchema: processOutputSchema(),
@@ -241,6 +241,11 @@ export function createCapabilityRegistry(dependencies) {
241
241
  path: z.string().min(1),
242
242
  limit: z.number().int().min(1).max(MAX_CODE_INTELLIGENCE_RESULT_LIMIT).optional(),
243
243
  }).strict(),
244
+ z.object({ operation: z.literal("managed.status") }).strict(),
245
+ z.object({
246
+ operation: z.literal("managed.install"),
247
+ servers: z.array(z.enum(["typescript", "pyright"])).min(1),
248
+ }).strict(),
244
249
  ]);
245
250
  return new CapabilityRegistry([
246
251
  {
@@ -273,7 +278,7 @@ export function createCapabilityRegistry(dependencies) {
273
278
  ...(dependencies.codeIntelligence
274
279
  ? [{
275
280
  name: "code.intelligence",
276
- description: "Read semantic code information through an available Language server without changing the Workspace.",
281
+ description: "Read semantic code information and, when explicitly enabled by the user, manage ForgeRelay-owned Language Servers for this instance.",
277
282
  guideName: "code-intelligence",
278
283
  readGuideBeforeFirstUse: true,
279
284
  batchPolicy: "parallel",
@@ -300,7 +305,7 @@ export function createCapabilityRegistry(dependencies) {
300
305
  ...(dependencies.subagentSession
301
306
  ? [{
302
307
  name: "subagent.session",
303
- description: "Coordinate provider-backed Subagent Sessions in the current Execution Workspace.",
308
+ description: "Delegate explicit work to provider-backed Subagent Sessions in the current Execution Workspace; disclose delegation and verify returned results before presenting them as final.",
304
309
  guideName: "subagents",
305
310
  readGuideBeforeFirstUse: true,
306
311
  batchPolicy: "unsupported",
@@ -94,6 +94,26 @@ export const workspaceSubagentProviderOutputSchema = z.object({
94
94
  export const workspaceAvailableAgentsFileOutputSchema = z.object({
95
95
  path: z.string(),
96
96
  });
97
+ export const managedWorktreeRecoveryOutputSchema = z.object({
98
+ classification: z.enum(["healthy", "recoverable", "manual-intervention"]),
99
+ conditions: z.array(z.enum([
100
+ "backing-missing",
101
+ "managed-branch-missing",
102
+ "git-registration-stale",
103
+ "git-registration-missing",
104
+ "git-registration-unavailable",
105
+ "branch-mismatch",
106
+ "source-missing",
107
+ "source-unavailable",
108
+ "target-branch-missing",
109
+ ])),
110
+ backing: z.enum(["present", "missing"]),
111
+ source: z.enum(["available", "missing", "unavailable"]),
112
+ gitRegistration: z.enum(["registered", "stale", "missing", "unavailable"]),
113
+ managedBranch: z.enum(["present", "missing", "unknown"]),
114
+ targetBranch: z.enum(["present", "missing", "unknown"]),
115
+ backingBranch: z.enum(["matching", "mismatched", "unavailable"]),
116
+ });
97
117
  export const workspaceInventoryEntryOutputSchema = z.object({
98
118
  label: z.string(),
99
119
  workspaceId: z.string(),
@@ -109,6 +129,7 @@ export const workspaceInventoryEntryOutputSchema = z.object({
109
129
  lastUsedAt: z.string(),
110
130
  idleMs: z.number().nonnegative(),
111
131
  rootValid: z.boolean(),
132
+ recovery: managedWorktreeRecoveryOutputSchema.optional(),
112
133
  current: z.boolean(),
113
134
  });
114
135
  export const workspaceInventorySummaryOutputSchema = z.object({
@@ -167,6 +188,7 @@ export const workspaceInspectionOutputSchema = z.union([
167
188
  lastUsedAt: z.string(),
168
189
  idleMs: z.number().nonnegative(),
169
190
  rootValid: z.boolean(),
191
+ recovery: managedWorktreeRecoveryOutputSchema.optional(),
170
192
  taskSummary: workspaceTaskInspectionSummaryOutputSchema.optional(),
171
193
  }),
172
194
  z.object({
@@ -186,6 +208,7 @@ export const workspaceInspectionOutputSchema = z.union([
186
208
  lastUsedAt: z.string().optional(),
187
209
  idleMs: z.number().nonnegative().optional(),
188
210
  rootValid: z.boolean().optional(),
211
+ recovery: managedWorktreeRecoveryOutputSchema.optional(),
189
212
  taskSummary: workspaceTaskInspectionSummaryOutputSchema.optional(),
190
213
  relay: z.string(),
191
214
  executionLocation: z.string(),
@@ -64,15 +64,18 @@ export function textBlock(text) {
64
64
  return { type: "text", text };
65
65
  }
66
66
  export function attachWorkspaceTaskReminder(result, reminder) {
67
- if (!reminder || toolResultIsError(result) || typeof result !== "object" || result === null)
67
+ return attachWorkspaceNotice(result, reminder);
68
+ }
69
+ export function attachWorkspaceContextUpdate(result, update) {
70
+ return attachWorkspaceNotice(result, update);
71
+ }
72
+ function attachWorkspaceNotice(result, notice) {
73
+ if (!notice || toolResultIsError(result) || typeof result !== "object" || result === null)
68
74
  return result;
69
75
  const content = result.content;
70
76
  if (!Array.isArray(content))
71
77
  return result;
72
- return {
73
- ...result,
74
- content: [...content, textBlock(reminder)],
75
- };
78
+ return { ...result, content: [...content, textBlock(notice)] };
76
79
  }
77
80
  export function textSummary(content) {
78
81
  const text = contentText(content);