@akira-tl/forgerelay 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -45,6 +45,7 @@ import { createCoreOperationExecutor, } from "./operations/core-operation-execut
45
45
  import { McpTransportRegistry, } from "./mcp-sessions.js";
46
46
  import { ProcessManager, resolveProcessId, } from "./process-sessions.js";
47
47
  import { createReviewCheckpointManager } from "./review-checkpoints.js";
48
+ import { RemoteWorkspaceRelay } from "./remote-workspace-relay.js";
48
49
  import { hostConversationScopeId, openAiConversationScopeId } from "./request-meta.js";
49
50
  import { ACTIVITY_PANEL_APP_LEGACY_URI, ACTIVITY_PANEL_APP_URI_TEMPLATE, MCP_APP_RESOURCE_TEMPLATE_REVISION, readActivityPanelAppManifestEntry, readWorkspaceAppManifestEntry, readWorkspaceLifecycleAppManifestEntry, resolveActivityPanelAppIdentity, resolveWorkspaceAppIdentity, resolveWorkspaceLifecycleAppIdentity, WORKSPACE_APP_LEGACY_URI, WORKSPACE_APP_URI_TEMPLATE, WORKSPACE_LIFECYCLE_APP_LEGACY_URI, WORKSPACE_LIFECYCLE_APP_URI_TEMPLATE, } from "./mcp-app-template.js";
50
51
  import { shutdownHttpServer } from "./server-shutdown.js";
@@ -1124,6 +1125,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
1124
1125
  }
1125
1126
  export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle, bashOutputStore, activityQueries) {
1126
1127
  const connectionScopeId = `mcp-connection:${randomUUID()}`;
1128
+ const remoteWorkspaces = new RemoteWorkspaceRelay(config.configDir, config.stateDir);
1127
1129
  const hostScopeIdFor = (requestMeta, transportSessionId) => hostConversationScopeId(requestMeta, transportSessionId, connectionScopeId);
1128
1130
  const toolDescriptions = buildToolDescriptions(config);
1129
1131
  const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result, (snapshot) => recordBashCompletion(activityLifecycle, bashOutputStore, snapshot.outputId)));
@@ -1735,6 +1737,26 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1735
1737
  instructions: buildServerInstructions(config),
1736
1738
  });
1737
1739
  const workspacePanelStates = new Map();
1740
+ const workspacePanelState = (workspaceId) => {
1741
+ const remembered = workspacePanelStates.get(workspaceId);
1742
+ if (remembered)
1743
+ return remembered;
1744
+ try {
1745
+ const workspace = workspaces.getWorkspace(workspaceId);
1746
+ return {
1747
+ workspaceId: workspace.id,
1748
+ root: workspace.root,
1749
+ path: workspace.root,
1750
+ mode: workspace.mode,
1751
+ sourceRoot: workspace.sourceRoot,
1752
+ instruction: `Use workspaceId ${workspace.id} for subsequent calls.`,
1753
+ summary: { mode: workspace.mode },
1754
+ };
1755
+ }
1756
+ catch {
1757
+ return undefined;
1758
+ }
1759
+ };
1738
1760
  const rememberWorkspacePanelState = (workspaceId, response) => {
1739
1761
  if (typeof response._meta !== "object" || response._meta === null)
1740
1762
  return;
@@ -1772,7 +1794,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1772
1794
  server.registerResource("ForgeRelay Activity Panel compatibility", new ResourceTemplate(ACTIVITY_PANEL_APP_URI_TEMPLATE, { list: undefined }), { ...activityPanelResourceMetadata, mimeType: RESOURCE_MIME_TYPE }, async (uri, _variables, extra) => readActivityPanelAppResource(config, uri.toString(), extra.sessionId));
1773
1795
  registerAppTool(server, "open_workspace", {
1774
1796
  title: "Open workspace",
1775
- description: "Open or resume a local coding workspace. Reuse the returned workspaceId for later calls. Default to checkout; use mode=\"worktree\" only when the user explicitly requests isolated or parallel Git work. Every call returns lightweight workspace metadata; bootstrap context is delivered automatically only when needed and can be explicitly suppressed or refreshed.",
1797
+ description: "Open or resume a coding workspace. Defaults to local execution; for a new workspace, relay may name a registered direct remote ForgeRelay. Reuse the returned Gateway workspaceId for later calls. Default to checkout; use mode=\"worktree\" only for explicitly isolated or parallel Git work. Bootstrap context is delivered automatically only when needed and can be suppressed or refreshed.",
1776
1798
  inputSchema: {
1777
1799
  action: z
1778
1800
  .enum(["open", "list"])
@@ -1782,6 +1804,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1782
1804
  .string()
1783
1805
  .optional()
1784
1806
  .describe("Project path to open. Required for action=open unless workspaceId is supplied. With mode=\"worktree\", this may also be a managed worktree path previously returned by ForgeRelay."),
1807
+ relay: z
1808
+ .string()
1809
+ .optional()
1810
+ .describe("Optional registered remote ForgeRelay alias. When supplied for action=open, the workspace is opened and executed on that remote instance while this Gateway returns its own workspaceId."),
1785
1811
  workspaceId: z
1786
1812
  .string()
1787
1813
  .optional()
@@ -1896,14 +1922,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1896
1922
  idempotentHint: false,
1897
1923
  openWorldHint: false,
1898
1924
  },
1899
- }, async ({ action = "open", path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, }, { _meta, sessionId }) => {
1925
+ }, async ({ action = "open", path, relay, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, }, { _meta, sessionId }) => {
1900
1926
  const startedAt = performance.now();
1901
1927
  const conversationScopeId = openAiConversationScopeId(_meta);
1902
1928
  const protectedWorkspaceIds = processSessions.activeWorkspaceIds();
1903
1929
  if (action === "list") {
1904
- if (path !== undefined || baseRef !== undefined || newWorktree !== undefined ||
1930
+ if (path !== undefined || relay !== undefined || baseRef !== undefined || newWorktree !== undefined ||
1905
1931
  newWorkspace !== undefined || context !== undefined) {
1906
- throw new Error("open_workspace action=list does not accept path, baseRef, newWorktree, newWorkspace, or context. Use root/workspaceId/mode/status/state/staleOnly for inventory filters.");
1932
+ throw new Error("open_workspace action=list does not accept path, relay, baseRef, newWorktree, newWorkspace, or context. Use root/workspaceId/mode/status/state/staleOnly for inventory filters.");
1907
1933
  }
1908
1934
  const inventory = await workspaces.listWorkspaces({ workspaceId, mode, root, status, state, staleOnly, offset, limit }, { conversationScopeId, protectedWorkspaceIds });
1909
1935
  const nextOffset = inventory.page.offset + inventory.page.limit;
@@ -1949,6 +1975,96 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1949
1975
  staleOnly !== undefined || offset !== undefined || limit !== undefined) {
1950
1976
  throw new Error("open_workspace inventory filters root, status, state, staleOnly, offset, and limit are only valid with action=list.");
1951
1977
  }
1978
+ if (relay !== undefined) {
1979
+ if (workspaceId !== undefined) {
1980
+ throw new Error("Relayed open_workspace requires a path; resuming a relayed workspace is not available in this tracer bullet.");
1981
+ }
1982
+ if (!path)
1983
+ throw new Error("Relayed open_workspace requires path.");
1984
+ const opened = await remoteWorkspaces.openWorkspace(relay, {
1985
+ path,
1986
+ mode,
1987
+ baseRef,
1988
+ newWorktree,
1989
+ newWorkspace,
1990
+ context,
1991
+ }, hostScopeIdFor(_meta, sessionId));
1992
+ const relayedSkills = Array.isArray(opened.skills)
1993
+ ? opened.skills
1994
+ : [];
1995
+ const relayedCapabilities = Array.isArray(opened.capabilityCatalog)
1996
+ ? opened.capabilityCatalog
1997
+ : [];
1998
+ const result = [
1999
+ `Opened relayed workspace ${opened.workspaceId}.`,
2000
+ `Execution remote: ${relay}`,
2001
+ `Root: ${opened.root}`,
2002
+ `Mode: ${opened.mode}`,
2003
+ relayedSkills.length > 0
2004
+ ? `Available skills: ${relayedSkills.map((skill) => String(skill.name ?? "")).filter(Boolean).join(", ")}`
2005
+ : undefined,
2006
+ relayedCapabilities.length > 0
2007
+ ? `Optional capabilities: ${relayedCapabilities.map((entry) => String(entry.name ?? "")).filter(Boolean).join(", ")}`
2008
+ : undefined,
2009
+ opened.instruction,
2010
+ ].filter(Boolean).join("\n");
2011
+ const response = {
2012
+ content: [textBlock(result)],
2013
+ _meta: {
2014
+ tool: "open_workspace",
2015
+ card: {
2016
+ workspaceId: opened.workspaceId,
2017
+ root: opened.root,
2018
+ path: opened.root,
2019
+ mode: opened.mode,
2020
+ relay,
2021
+ instruction: opened.instruction,
2022
+ summary: { mode: opened.mode, relay },
2023
+ },
2024
+ },
2025
+ structuredContent: {
2026
+ action: "open",
2027
+ workspaceId: opened.workspaceId,
2028
+ root: opened.root,
2029
+ mode: opened.mode,
2030
+ ...(opened.sourceRoot ? { sourceRoot: opened.sourceRoot } : {}),
2031
+ ...(opened.contextFingerprint !== undefined
2032
+ ? { contextFingerprint: opened.contextFingerprint }
2033
+ : {}),
2034
+ ...(opened.capabilityFingerprint !== undefined
2035
+ ? { capabilityFingerprint: opened.capabilityFingerprint }
2036
+ : {}),
2037
+ ...(opened.capabilityCatalog !== undefined
2038
+ ? { capabilityCatalog: opened.capabilityCatalog }
2039
+ : {}),
2040
+ ...(opened.capabilityGuides !== undefined
2041
+ ? { capabilityGuides: opened.capabilityGuides }
2042
+ : {}),
2043
+ ...(opened.agentsFiles !== undefined ? { agentsFiles: opened.agentsFiles } : {}),
2044
+ ...(opened.availableAgentsFiles !== undefined
2045
+ ? { availableAgentsFiles: opened.availableAgentsFiles }
2046
+ : {}),
2047
+ ...(opened.skills !== undefined ? { skills: opened.skills } : {}),
2048
+ ...(opened.agentProviders !== undefined
2049
+ ? { agentProviders: opened.agentProviders }
2050
+ : {}),
2051
+ ...(opened.agents !== undefined ? { agents: opened.agents } : {}),
2052
+ ...(opened.skillDiagnostics !== undefined
2053
+ ? { skillDiagnostics: opened.skillDiagnostics }
2054
+ : {}),
2055
+ instruction: opened.instruction,
2056
+ },
2057
+ };
2058
+ logToolCall(config, {
2059
+ tool: "open_workspace",
2060
+ action: "relay",
2061
+ path: opened.root,
2062
+ success: true,
2063
+ durationMs: Math.round(performance.now() - startedAt),
2064
+ });
2065
+ rememberWorkspacePanelState(opened.workspaceId, response);
2066
+ return response;
2067
+ }
1952
2068
  const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, contextFingerprint, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context }, {
1953
2069
  conversationScopeId,
1954
2070
  protectedWorkspaceIds,
@@ -2144,7 +2260,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2144
2260
  rememberWorkspacePanelState(workspace.id, response);
2145
2261
  return response;
2146
2262
  });
2147
- registerActivityQueryTools(server, activityQueries, connectionScopeId, toolWidgetDescriptorMeta(config, "activity")._meta, config.activityPanelExpanded, config.logging, (workspaceId) => workspacePanelStates.get(workspaceId));
2263
+ registerActivityQueryTools(server, activityQueries, connectionScopeId, toolWidgetDescriptorMeta(config, "activity")._meta, config.activityPanelExpanded, config.logging, workspacePanelState, {
2264
+ panel: async (workspaceId, conversationScopeId) => remoteWorkspaces.has(workspaceId)
2265
+ ? remoteWorkspaces.activityPanel(workspaceId, conversationScopeId)
2266
+ : undefined,
2267
+ snapshot: (input, conversationScopeId) => remoteWorkspaces.activitySnapshot(input, conversationScopeId),
2268
+ detail: (turnId, activityId, conversationScopeId) => remoteWorkspaces.activityDetail(turnId, activityId, conversationScopeId),
2269
+ output: (turnId, outputId, conversationScopeId) => remoteWorkspaces.activityOutput(turnId, outputId, conversationScopeId),
2270
+ });
2148
2271
  registerAppTool(server, toolNames.capability, {
2149
2272
  title: "Use optional capability",
2150
2273
  description: "Describe or run one optional ForgeRelay capability advertised by open_workspace. Use describe when the capability contract is unfamiliar, then read its advertised guide if needed. Run dispatches only explicitly registered capabilities; it cannot invoke arbitrary shell commands, URLs, or methods.",
@@ -2182,6 +2305,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2182
2305
  openWorldHint: true,
2183
2306
  },
2184
2307
  }, async ({ workspaceId, name, action, arguments: capabilityArguments, file }, extra) => {
2308
+ if (remoteWorkspaces.has(workspaceId)) {
2309
+ return remoteWorkspaces.capability(workspaceId, {
2310
+ name,
2311
+ action,
2312
+ ...(capabilityArguments !== undefined ? { arguments: capabilityArguments } : {}),
2313
+ ...(file !== undefined ? { file } : {}),
2314
+ }, hostScopeIdFor(extra._meta, extra.sessionId));
2315
+ }
2185
2316
  if (action === "run" && name === "batch.execute") {
2186
2317
  const workspace = workspaces.getWorkspace(workspaceId);
2187
2318
  const startedAt = performance.now();
@@ -2318,6 +2449,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2318
2449
  _meta: {},
2319
2450
  annotations: WRITE_TOOL_ANNOTATIONS,
2320
2451
  }, async ({ workspaceId, commitMessage }, extra) => {
2452
+ if (remoteWorkspaces.has(workspaceId)) {
2453
+ const response = await remoteWorkspaces.closeWorkspace(workspaceId, commitMessage, hostScopeIdFor(extra._meta, extra.sessionId));
2454
+ workspacePanelStates.delete(workspaceId);
2455
+ return response;
2456
+ }
2321
2457
  const workspace = workspaces.getWorkspace(workspaceId);
2322
2458
  const response = await runToolWithHooks(hooks, {
2323
2459
  signal: extra.signal,
@@ -2470,6 +2606,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2470
2606
  if ((path === undefined) === (paths === undefined)) {
2471
2607
  throw new Error("read requires exactly one of path or paths.");
2472
2608
  }
2609
+ if (remoteWorkspaces.has(workspaceId)) {
2610
+ return remoteWorkspaces.read(workspaceId, { path, paths, offset, limit }, hostScopeIdFor(extra._meta, extra.sessionId));
2611
+ }
2473
2612
  if (path !== undefined) {
2474
2613
  return coreOperations.read({ workspaceId, path, offset, limit }, {
2475
2614
  requestMeta: extra._meta,
@@ -2548,11 +2687,16 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2548
2687
  outputSchema: resultOutputSchema(),
2549
2688
  ...toolWidgetDescriptorMeta(config, "write"),
2550
2689
  annotations: WRITE_TOOL_ANNOTATIONS,
2551
- }, async ({ workspaceId, ...input }, extra) => coreOperations.write({ workspaceId, ...input }, {
2552
- requestMeta: extra._meta,
2553
- signal: extra.signal,
2554
- sessionId: extra.sessionId,
2555
- }));
2690
+ }, async ({ workspaceId, ...input }, extra) => {
2691
+ if (remoteWorkspaces.has(workspaceId)) {
2692
+ return remoteWorkspaces.write(workspaceId, input, hostScopeIdFor(extra._meta, extra.sessionId));
2693
+ }
2694
+ return coreOperations.write({ workspaceId, ...input }, {
2695
+ requestMeta: extra._meta,
2696
+ signal: extra.signal,
2697
+ sessionId: extra.sessionId,
2698
+ });
2699
+ });
2556
2700
  registerAppTool(server, toolNames.edit, {
2557
2701
  title: "Edit file",
2558
2702
  description: toolDescriptions.edit,
@@ -2597,6 +2741,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2597
2741
  if ((path === undefined) === (paths === undefined)) {
2598
2742
  throw new Error("edit requires exactly one of path or paths.");
2599
2743
  }
2744
+ if (remoteWorkspaces.has(workspaceId)) {
2745
+ return remoteWorkspaces.edit(workspaceId, { path, paths, edits }, hostScopeIdFor(extra._meta, extra.sessionId));
2746
+ }
2600
2747
  if (path !== undefined) {
2601
2748
  return coreOperations.edit({ workspaceId, path, edits }, {
2602
2749
  requestMeta: extra._meta,
@@ -2626,11 +2773,16 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2626
2773
  }),
2627
2774
  ...toolWidgetDescriptorMeta(config, "edit"),
2628
2775
  annotations: EDIT_TOOL_ANNOTATIONS,
2629
- }, async ({ workspaceId, path, newPath }, extra) => coreOperations.rename({ workspaceId, path, newPath }, {
2630
- requestMeta: extra._meta,
2631
- signal: extra.signal,
2632
- sessionId: extra.sessionId,
2633
- }));
2776
+ }, async ({ workspaceId, path, newPath }, extra) => {
2777
+ if (remoteWorkspaces.has(workspaceId)) {
2778
+ return remoteWorkspaces.rename(workspaceId, { path, newPath }, hostScopeIdFor(extra._meta, extra.sessionId));
2779
+ }
2780
+ return coreOperations.rename({ workspaceId, path, newPath }, {
2781
+ requestMeta: extra._meta,
2782
+ signal: extra.signal,
2783
+ sessionId: extra.sessionId,
2784
+ });
2785
+ });
2634
2786
  registerAppTool(server, toolNames.delete, {
2635
2787
  title: "Delete path",
2636
2788
  description: toolDescriptions.delete,
@@ -2665,6 +2817,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2665
2817
  if ((path === undefined) === (paths === undefined)) {
2666
2818
  throw new Error("delete requires exactly one of path or paths.");
2667
2819
  }
2820
+ if (remoteWorkspaces.has(workspaceId)) {
2821
+ return remoteWorkspaces.delete(workspaceId, { path, paths, recursive }, hostScopeIdFor(extra._meta, extra.sessionId));
2822
+ }
2668
2823
  if (path !== undefined) {
2669
2824
  return coreOperations.delete({ workspaceId, path, recursive }, {
2670
2825
  requestMeta: extra._meta,
@@ -2835,6 +2990,23 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2835
2990
  ...toolWidgetDescriptorMeta(config, "shell"),
2836
2991
  annotations: SHELL_TOOL_ANNOTATIONS,
2837
2992
  }, async ({ workspaceId, action = "run", command, processId, outputId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens, }, extra) => {
2993
+ if (remoteWorkspaces.has(workspaceId)) {
2994
+ return remoteWorkspaces.bash(workspaceId, {
2995
+ action,
2996
+ ...(command !== undefined ? { command } : {}),
2997
+ ...(processId !== undefined ? { processId } : {}),
2998
+ ...(outputId !== undefined ? { outputId } : {}),
2999
+ ...(input !== undefined ? { input } : {}),
3000
+ ...(interrupt !== undefined ? { interrupt } : {}),
3001
+ ...(tty !== undefined ? { tty } : {}),
3002
+ ...(columns !== undefined ? { columns } : {}),
3003
+ ...(rows !== undefined ? { rows } : {}),
3004
+ ...(workingDirectory !== undefined ? { workingDirectory } : {}),
3005
+ ...(yieldTimeMs !== undefined ? { yieldTimeMs } : {}),
3006
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3007
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
3008
+ }, hostScopeIdFor(extra._meta, extra.sessionId));
3009
+ }
2838
3010
  const workspace = workspaces.getWorkspace(workspaceId);
2839
3011
  if (action === "run") {
2840
3012
  if (!command)
@@ -3069,6 +3241,8 @@ export function createServer(config = loadConfig(), options = {}) {
3069
3241
  });
3070
3242
  app.use(createForgeRelayAuthRouter({
3071
3243
  provider: oauthProvider,
3244
+ cliAuthenticationProvider: oauthProvider,
3245
+ instanceId: config.instanceId,
3072
3246
  issuerUrl: new URL(config.publicBaseUrl),
3073
3247
  resourceServerUrl,
3074
3248
  scopesSupported: config.oauth.scopes,
@@ -1,9 +1,13 @@
1
- import { randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { expandHomePath } from "./roots.js";
6
6
  import { mergeHookConfigs, parseHookFile, } from "./hooks.js";
7
+ const AUTH_LOCK_RETRY_MS = 10;
8
+ const AUTH_LOCK_TIMEOUT_MS = 5_000;
9
+ const AUTH_LOCK_STALE_MS = 30_000;
10
+ const AUTH_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
7
11
  export function forgerelayConfigDir(env = process.env) {
8
12
  const explicit = env.FORGERELAY_CONFIG_DIR ?? env.DEVSPACE_CONFIG_DIR;
9
13
  if (explicit)
@@ -64,12 +68,81 @@ export function writeForgeRelayConfig(config, env = process.env) {
64
68
  export function writeForgeRelayAuth(auth, env = process.env) {
65
69
  const filePath = forgerelayAuthPath(env);
66
70
  mkdirSync(forgerelayConfigDir(env), { recursive: true });
67
- writeJsonFile(filePath, auth, 0o600);
68
- return filePath;
71
+ return withAuthFileLock(filePath, () => {
72
+ writeJsonFile(filePath, auth, 0o600);
73
+ return filePath;
74
+ });
69
75
  }
70
76
  export function generateOwnerToken() {
71
77
  return randomBytes(32).toString("base64url");
72
78
  }
79
+ export function generateInstanceId() {
80
+ return `forge-${randomUUID()}`;
81
+ }
82
+ export function ensureForgeRelayInstanceId(env = process.env) {
83
+ const existing = loadForgeRelayFiles(env).auth.instanceId?.trim();
84
+ if (existing)
85
+ return existing;
86
+ let resolved = "";
87
+ updateForgeRelayAuth((auth) => {
88
+ const current = auth.instanceId?.trim();
89
+ if (current) {
90
+ resolved = current;
91
+ return auth;
92
+ }
93
+ resolved = generateInstanceId();
94
+ return { ...auth, instanceId: resolved };
95
+ }, env);
96
+ return resolved;
97
+ }
98
+ function normalizeRemoteAlias(alias) {
99
+ const normalized = alias.trim();
100
+ if (!normalized || /\s/.test(normalized)) {
101
+ throw new Error("Remote alias must be a non-empty name without whitespace.");
102
+ }
103
+ return normalized;
104
+ }
105
+ export function writeForgeRelayRemote(alias, remote, env = process.env) {
106
+ const normalizedAlias = normalizeRemoteAlias(alias);
107
+ return updateForgeRelayAuth((auth) => {
108
+ const remotes = { ...(auth.remotes ?? {}) };
109
+ const existing = remotes[normalizedAlias];
110
+ if (existing && existing.instanceId !== remote.instanceId) {
111
+ throw new Error(`Remote alias ${normalizedAlias} already belongs to another ForgeRelay instance.`);
112
+ }
113
+ const duplicate = Object.entries(remotes).find(([name, record]) => name !== normalizedAlias && record.instanceId === remote.instanceId);
114
+ if (duplicate) {
115
+ throw new Error(`ForgeRelay instance is already registered as ${duplicate[0]}; rename that remote instead.`);
116
+ }
117
+ remotes[normalizedAlias] = remote;
118
+ return { ...auth, remotes };
119
+ }, env);
120
+ }
121
+ export function renameForgeRelayRemote(fromAlias, toAlias, env = process.env) {
122
+ const from = normalizeRemoteAlias(fromAlias);
123
+ const to = normalizeRemoteAlias(toAlias);
124
+ return updateForgeRelayAuth((auth) => {
125
+ const remotes = { ...(auth.remotes ?? {}) };
126
+ const remote = remotes[from];
127
+ if (!remote)
128
+ throw new Error(`Unknown remote alias: ${from}`);
129
+ if (from !== to && remotes[to])
130
+ throw new Error(`Remote alias already exists: ${to}`);
131
+ delete remotes[from];
132
+ remotes[to] = remote;
133
+ return { ...auth, remotes };
134
+ }, env);
135
+ }
136
+ export function removeForgeRelayRemote(alias, env = process.env) {
137
+ const normalizedAlias = normalizeRemoteAlias(alias);
138
+ return updateForgeRelayAuth((auth) => {
139
+ const remotes = { ...(auth.remotes ?? {}) };
140
+ if (!remotes[normalizedAlias])
141
+ throw new Error(`Unknown remote alias: ${normalizedAlias}`);
142
+ delete remotes[normalizedAlias];
143
+ return { ...auth, remotes };
144
+ }, env);
145
+ }
73
146
  export function ensureForgeRelayDefaultSkills(env = process.env) {
74
147
  const targetPath = join(forgerelaySkillsDir(env), "subagent-delegation", "SKILL.md");
75
148
  if (existsSync(targetPath))
@@ -124,6 +197,59 @@ function readJsonFile(filePath) {
124
197
  throw new Error(`Unable to read ${filePath}: ${reason}`);
125
198
  }
126
199
  }
200
+ function updateForgeRelayAuth(update, env) {
201
+ const filePath = forgerelayAuthPath(env);
202
+ mkdirSync(forgerelayConfigDir(env), { recursive: true });
203
+ return withAuthFileLock(filePath, () => {
204
+ const auth = existsSync(filePath) ? readJsonFile(filePath) : {};
205
+ writeJsonFile(filePath, update(auth), 0o600);
206
+ return filePath;
207
+ });
208
+ }
209
+ function withAuthFileLock(filePath, operation) {
210
+ const lockPath = `${filePath}.lock`;
211
+ const deadline = Date.now() + AUTH_LOCK_TIMEOUT_MS;
212
+ for (;;) {
213
+ try {
214
+ const fd = openSync(lockPath, "wx", 0o600);
215
+ closeSync(fd);
216
+ break;
217
+ }
218
+ catch (error) {
219
+ const code = error.code;
220
+ if (code !== "EEXIST")
221
+ throw error;
222
+ try {
223
+ if (Date.now() - statSync(lockPath).mtimeMs > AUTH_LOCK_STALE_MS) {
224
+ rmSync(lockPath, { force: true });
225
+ continue;
226
+ }
227
+ }
228
+ catch (statError) {
229
+ if (statError.code === "ENOENT")
230
+ continue;
231
+ throw statError;
232
+ }
233
+ if (Date.now() >= deadline) {
234
+ throw new Error(`Timed out waiting for ForgeRelay auth lock: ${lockPath}`);
235
+ }
236
+ Atomics.wait(AUTH_LOCK_SLEEP, 0, 0, AUTH_LOCK_RETRY_MS);
237
+ }
238
+ }
239
+ try {
240
+ return operation();
241
+ }
242
+ finally {
243
+ rmSync(lockPath, { force: true });
244
+ }
245
+ }
127
246
  function writeJsonFile(filePath, value, mode) {
128
- writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode });
247
+ const tempPath = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
248
+ try {
249
+ writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n", { mode });
250
+ renameSync(tempPath, filePath);
251
+ }
252
+ finally {
253
+ rmSync(tempPath, { force: true });
254
+ }
129
255
  }
@@ -386,7 +386,7 @@ Hooks v1 是自动生命周期规则。规则由用户或 Agent 主动写入;
386
386
  <workspace>/.forgerelay/hooks/<hook-name>.json
387
387
  ```
388
388
 
389
- 文件名去掉 `.json` 后就是 Hook 名,也是日志和 Agent-visible report 中显示的名称。例如 `release-tag-local-ci.json` 会显示为 `release-tag-local-ci`。目录内按文件名字典序执行;需要显式排序时可以使用 `10-release-verify.json`、`20-package-inspection.json` 这样的前缀。ForgeRelay 只读取普通 `*.json` 文件,所以临时停用某条 Hook 时可以把扩展名改掉。
389
+ 文件名去掉 `.json` 后就是 Hook 名,也是日志和 Agent-visible report 中显示的名称。例如 `release-tag-gate.json` 会显示为 `release-tag-gate`。目录内按文件名字典序执行;需要显式排序时可以使用 `10-release-verify.json`、`20-package-inspection.json` 这样的前缀。ForgeRelay 只读取普通 `*.json` 文件,所以临时停用某条 Hook 时可以把扩展名改掉。
390
390
 
391
391
  全局 Hook 在 server 启动时读取,修改后需要重启 ForgeRelay;项目目录在每次事件时重新读取,所以 Agent 修改项目 Hook 后不需要重启。全局规则先执行,项目规则随后执行,两边都只做追加,不互相覆盖。
392
392
 
@@ -405,7 +405,7 @@ Hooks v1 是自动生命周期规则。规则由用户或 Agent 主动写入;
405
405
  }
406
406
  ```
407
407
 
408
- 这个例子可以保存为 `.forgerelay/hooks/release-tag-local-ci.json`。耗时的 `npm run release:verify` 应在已提交的 release-ready HEAD 上提前运行,并在 `.git/forgerelay/` 写入 release proof。Agent 之后通过 ForgeRelay `bash` 请求推送稳定版本 tag 时,Hook 只快速校验 proof、当前 HEAD/package version、clean working tree(含 untracked)与本地 tag 指向;全部一致才执行原始 `git push`。因此发布 gate 不再依赖一个持续数分钟的单次 MCP request,同时任何验证后的代码变化都会使 proof 失效并阻断推送。
408
+ 这个例子可以保存为 `.forgerelay/hooks/release-tag-gate.json`。Agent 通过 ForgeRelay `bash` 请求推送稳定版本 tag 时,Hook 只执行轻量仓库状态门禁:拒绝 force/delete 形式,校验 clean working tree(含 untracked)、tag 与 package version 一致,并要求本地 tag 指向当前 HEAD。Hook 不运行、也不要求本地 CI;tag 推送后由 GitHub Actions Linux/macOS/Windows 矩阵执行权威验证,全部通过后发布 job 才会继续。`npm run release:verify` 仅用于需要时本地复现云端环境。
409
409
 
410
410
  独立 Hook 文件支持这些顶层字段:
411
411
 
@@ -472,7 +472,7 @@ Matcher 匹配 ForgeRelay 收到的那次 tool request,不会窥探该命令
472
472
 
473
473
  ```text
474
474
  Hook results:
475
- ✓ release-tag-local-ci (BeforeTool, project) passed in 42ms
475
+ ✓ release-tag-gate (BeforeTool, project) passed in 42ms
476
476
  ```
477
477
 
478
478
  阻断失败会明确显示 `failed`。ForgeRelay 的 server instructions 要求 Agent 在出现 Hook results 时,向用户说明有意义的 Hook 是否通过或阻断了操作。异步 subagent 的 `SubagentStart` / `SubagentStop` 报告会随 session 持久化,并由 `forgerelay agents show` 展示。
@@ -79,25 +79,20 @@ The bump commands update:
79
79
 
80
80
  They do not commit, tag, push, or publish.
81
81
 
82
- Run the full local release gate with:
82
+ For local diagnosis or cloud-parity reproduction, you can optionally run:
83
83
 
84
84
  ```bash
85
85
  npm run release:verify
86
86
  ```
87
87
 
88
- `release:verify` checks the current committed release-ready runtime and then runs a focused
89
- `release:parity` gate in an isolated Node 22.19.0 sandbox. After every check passes it
90
- records a local release proof under `.git/forgerelay/`, binding that verification to
91
- current HEAD and the package version. The parity sandbox
92
- performs its own `npm ci` so native addons use the same Node ABI as cloud CI,
93
- then reruns the LSP/release tests most sensitive to event-loop timing, process
94
- lifecycle, path canonicalization, executable discovery, and cleanup behavior.
95
- It also tests that a command which exists on `PATH` but fails its `--version`
96
- preflight is treated as unavailable rather than as an installed Language server.
88
+ `release:verify` runs the same focused parity checks in an isolated Node 22.19.0 sandbox
89
+ and records a local proof under `.git/forgerelay/` for debugging/audit purposes. It is
90
+ not a prerequisite for pushing a release tag. The authoritative release verification
91
+ is the tag-triggered GitHub Actions matrix on Linux, macOS, and Windows.
97
92
 
98
- Cloud verification and the publication job are both pinned to Node 22.19.0, the
99
- minimum supported Node release, so local parity and the release runners use the
100
- same runtime instead of drifting across separate Node 22/24 variants.
93
+ Cloud verification and the publication job are pinned to Node 22.19.0, the minimum
94
+ supported Node release. The optional local parity tool uses the same runtime so a cloud
95
+ failure can be reproduced locally without making local execution part of the release gate.
101
96
 
102
97
  Validate a specific tag with:
103
98
 
@@ -172,11 +167,8 @@ npm publishing token.
172
167
  3. Run the appropriate `release:patch`, `release:minor`, or `release:major`
173
168
  command.
174
169
  4. Review the generated version and changelog diff, then commit the release-ready code and metadata.
175
- 5. Run `npm run release:verify` locally on that clean committed HEAD. This full local gate includes the isolated
176
- Node 22.19.0 parity sandbox and records the local release proof consumed by the tag-push Hook; ordinary development
177
- pushes do not need to run the full release gate.
178
- 6. Push the verified `main` commit without changing it afterward.
179
- 7. Create the exact version tag, for example:
170
+ 5. Push the release-ready `main` commit without changing it afterward.
171
+ 6. Create the exact version tag, for example:
180
172
 
181
173
  ```bash
182
174
  git tag v0.2.0
@@ -185,7 +177,7 @@ npm publishing token.
185
177
 
186
178
  The tag push is the publication action. The release workflow publishes npm only after cloud CI passes, then extracts the matching `CHANGELOG.md` release section as the GitHub Release body. Keep `Unreleased` user-facing and structured (`Added`, `Changed`, `Fixed`, `Security`) because those notes are what users see on the Release page.
187
179
 
188
- Project release Hooks match the stable tag-push command as a substring of the ForgeRelay shell request. A compound command is allowed: when `commandRegex` matches `git push origin vX.Y.Z`, the Hook receives that matched command as `FORGERELAY_HOOK_PAYLOAD.command` and retains the complete shell request as `originalCommand` when they differ. The release Hook does **not** rerun the multi-minute local gate inside the tag-push MCP request. Instead it quickly verifies the proof written by `release:verify`, requires a clean working tree including untracked files, requires the proof HEAD/package version to equal the current release state, and requires the local tag to resolve to that same verified HEAD. Any change after verification invalidates the proof and blocks the push until `release:verify` is rerun.
180
+ Project release Hooks match the stable tag-push command as a substring of the ForgeRelay shell request. A compound command is allowed: when `commandRegex` matches `git push origin vX.Y.Z`, the Hook receives that matched command as `FORGERELAY_HOOK_PAYLOAD.command` and retains the complete shell request as `originalCommand` when they differ. The release Hook is intentionally lightweight: it rejects force/deletion forms, requires a clean working tree including untracked files, requires the tag to match the package version, and requires the local tag to resolve to current HEAD. It does not run or require local CI. The pushed tag then starts the authoritative Linux/macOS/Windows cloud verification; publication cannot run unless that matrix succeeds.
189
181
 
190
182
  ## Attribution guardrails
191
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -41,10 +41,13 @@
41
41
  "debug:serve": "node scripts/debug/serve.mjs",
42
42
  "debug:accept": "node scripts/debug/accept.mjs",
43
43
  "lsp:interop": "node scripts/lsp-interop.mjs",
44
+ "ci:verify": "node scripts/ci/verify.mjs",
44
45
  "release:parity": "node scripts/release-parity.mjs",
46
+ "release:pack": "node scripts/release/pack.mjs",
47
+ "release:publish": "node scripts/release/publish.mjs",
45
48
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
46
49
  "start": "node dist/cli.js serve",
47
- "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
50
+ "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
48
51
  "typecheck": "tsc -p tsconfig.json --noEmit",
49
52
  "release:check": "node scripts/release-version.mjs check",
50
53
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ const npmCli = process.env.npm_execpath;
6
+ if (!npmCli) {
7
+ throw new Error("ci:verify must be launched through npm so npm_execpath is available");
8
+ }
9
+
10
+ console.log(`CI environment: ${process.platform}/${process.arch} ${process.version}`);
11
+ runNpm(["--version"], "npm version");
12
+ runNpm(["run", "release:check"], "Release metadata");
13
+ runNpm(["run", "typecheck"], "Typecheck");
14
+ runNpm(["test"], "Full test suite");
15
+ runNpm(["run", "build"], "Build");
16
+ runNpm(["run", "lsp:interop"], "Optional LSP interoperability");
17
+ run(process.execPath, ["dist/cli.js", "doctor"], "Doctor");
18
+
19
+ console.log("CI verification passed.");
20
+
21
+ function runNpm(args, label) {
22
+ run(process.execPath, [npmCli, ...args], label);
23
+ }
24
+
25
+ function run(command, args, label) {
26
+ console.log(`\n== ${label} ==`);
27
+ const result = spawnSync(command, args, {
28
+ cwd: process.cwd(),
29
+ env: process.env,
30
+ stdio: "inherit",
31
+ windowsHide: true,
32
+ shell: false,
33
+ });
34
+ if (result.error) throw result.error;
35
+ if (result.status !== 0) {
36
+ throw new Error(`${label} failed with exit ${result.status ?? "unknown"}`);
37
+ }
38
+ }