@akira-tl/forgerelay 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.6] - 2026-08-10
8
+
9
+ ### Changed
10
+
11
+ - Running shell commands now expose canonical `processId` handles; `write_stdin` accepts `processId`, while the former process `sessionId` remains a deprecated compatibility alias throughout 0.2.x.
12
+ - ForgeRelay now names protocol-level MCP state as a transport session in internal/debug terminology. Workspace identity remains `workspaceId`, one-request tracing remains `requestId`, and third-party provider session identifiers are unchanged.
13
+ - Process elapsed time now uses a monotonic clock, preventing negative `wallTimeMs` values when the operating-system wall clock is adjusted while a long-running command or release Hook is executing.
14
+
15
+ ## [0.2.5] - 2026-08-10
16
+
17
+ ### Changed
18
+
19
+ - Human-facing `pretty` logs are workspace-first: normal tool and hook lines no longer show transient MCP transport session IDs, MCP session create/close lifecycle events are debug-only, and project names receive stable per-project terminal colors while the logical `ws_...` identifier remains visible.
20
+ - MCP App tool metadata now uses a content-hashed UI resource URI derived from the built JavaScript/CSS, advertises both `model` and `app` visibility, and includes the ChatGPT `openai/outputTemplate` compatibility alias. Legacy `ui://forgerelay/workspace-app.html` and historical `workspace-app-*.html` pointers continue to resolve to the current template so stale ChatGPT metadata snapshots do not fail with a missing resource. Debug logs now distinguish current, legacy, and historical template reads, and real acceptance exercises current/compatibility resources plus the referenced JavaScript asset.
21
+ - `~/...` paths are expanded before allowed-root resolution, so advertised skills rendered with home-relative paths can be read directly instead of being interpreted as a literal `~` directory under the workspace.
22
+ - Successful MCP session shutdown and idle-cleanup lifecycle logs moved to `debug`; individual session close failures remain visible as warnings.
23
+
7
24
  ## [0.2.4] - 2026-08-09
8
25
 
9
26
  ### Added
@@ -7,7 +7,7 @@ import * as z from "zod/v4";
7
7
  import { ArtifactError } from "./artifact-error.js";
8
8
  import { runToolWithHooks } from "./hooks.js";
9
9
  import { describeIncomingArtifactValue, IncomingArtifactAdapterRegistry, } from "./incoming-artifacts.js";
10
- import { logEvent, sessionIdPrefix, workspaceLogLabel } from "./logger.js";
10
+ import { logEvent, workspaceLogLabel } from "./logger.js";
11
11
  const ARTIFACT_WRITE_ANNOTATIONS = {
12
12
  readOnlyHint: false,
13
13
  destructiveHint: false,
@@ -47,7 +47,7 @@ export function registerArtifactTools(server, { config, workspaces, hooks, incom
47
47
  },
48
48
  _meta: { "openai/fileParams": ["file"] },
49
49
  annotations: ARTIFACT_WRITE_ANNOTATIONS,
50
- }, async (input, extra) => {
50
+ }, async (input) => {
51
51
  const workspace = workspaces.getWorkspace(input.workspaceId);
52
52
  return runToolWithHooks(hooks, {
53
53
  tool: "download_artifact",
@@ -61,7 +61,6 @@ export function registerArtifactTools(server, { config, workspaces, hooks, incom
61
61
  changedPaths: (result) => [result.structuredContent.path],
62
62
  operation: () => executeArtifactTool(config, input, {
63
63
  workspace: workspaceLogLabel(workspace.root, workspace.id),
64
- session: sessionIdPrefix(extra?.sessionId),
65
64
  }, async () => {
66
65
  const downloaded = await downloadIncomingArtifact({
67
66
  registry: incomingRegistry,
package/dist/logger.js CHANGED
@@ -13,6 +13,14 @@ const LEVEL_STYLE = {
13
13
  info: "green",
14
14
  debug: "gray",
15
15
  };
16
+ const WORKSPACE_PROJECT_COLORS = [
17
+ "cyanBright",
18
+ "greenBright",
19
+ "yellowBright",
20
+ "magentaBright",
21
+ "blueBright",
22
+ "whiteBright",
23
+ ];
16
24
  export function shouldLog(config, level) {
17
25
  return LEVEL_WEIGHT[config.level] >= LEVEL_WEIGHT[level];
18
26
  }
@@ -53,9 +61,11 @@ export function requestIp(req, trustProxy) {
53
61
  export function requestPath(req) {
54
62
  return req.path || req.url.split("?")[0] || req.url;
55
63
  }
56
- export function sessionIdPrefix(sessionId) {
57
- return sessionId ? sessionId.slice(0, 8) : undefined;
64
+ export function transportSessionIdPrefix(transportSessionId) {
65
+ return transportSessionId ? transportSessionId.slice(0, 8) : undefined;
58
66
  }
67
+ /** @deprecated Use transportSessionIdPrefix. */
68
+ export const sessionIdPrefix = transportSessionIdPrefix;
59
69
  export function workspaceLogLabel(root, workspaceId) {
60
70
  const shortWorkspaceId = workspaceId.startsWith("ws_")
61
71
  ? `ws_${workspaceId.slice(3, 11)}`
@@ -70,12 +80,16 @@ export function formatPrettyLogEntry(entry, options = {}) {
70
80
  const level = logLevel(entry.level);
71
81
  const time = formatTimestamp(entry.ts);
72
82
  const source = stringField(entry.workspace) ?? stringField(entry.workspaceId) ?? "forgerelay";
73
- const session = stringField(entry.session) ?? stringField(entry.sessionIdPrefix);
83
+ const transportSession = level === "debug"
84
+ ? stringField(entry.transportSessionIdPrefix)
85
+ ?? stringField(entry.session)
86
+ ?? stringField(entry.sessionIdPrefix)
87
+ : undefined;
74
88
  const prefix = [
75
89
  style("gray", time, options),
76
90
  `[${style(LEVEL_STYLE[level], level.toUpperCase(), options)}]`,
77
- style(["cyan", "underline"], source, options),
78
- session ? style("gray", `session:${session}`, options) : undefined,
91
+ formatPrettySource(source, options),
92
+ transportSession ? style("gray", `transport:${transportSession}`, options) : undefined,
79
93
  style("gray", "|", options),
80
94
  ].filter((value) => Boolean(value)).join(" ");
81
95
  return `${prefix} ${formatPrettyMessage(entry, options)}`;
@@ -92,12 +106,24 @@ function formatPrettyMessage(entry, options) {
92
106
  return formatHookMessage(entry, options);
93
107
  case "http_request":
94
108
  return formatHttpMessage(entry, options);
109
+ case "mcp_request":
110
+ return formatMcpRequestMessage(entry);
111
+ case "mcp_app_template_read":
112
+ return formatAppTemplateMessage(entry, options, false);
113
+ case "mcp_app_template_read_failed":
114
+ return formatAppTemplateMessage(entry, options, true);
115
+ case "mcp_transport_session_created":
95
116
  case "mcp_session_created":
96
- return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} created`;
117
+ return `transport session ${transportSessionPrefix(entry) ?? "unknown"} created`;
118
+ case "mcp_transport_session_closed":
97
119
  case "mcp_session_closed":
98
- return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} closed`;
120
+ return `transport session ${transportSessionPrefix(entry) ?? "unknown"} closed`;
121
+ case "mcp_transport_sessions_closed":
122
+ case "mcp_sessions_closed":
123
+ return `${numberField(entry.count) ?? 0} transport sessions closed`;
124
+ case "mcp_transport_session_close_failed":
99
125
  case "mcp_session_close_failed":
100
- return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} close -> ${style("red", "error", options)}`;
126
+ return `transport session ${transportSessionPrefix(entry) ?? "unknown"} close -> ${style("red", "error", options)}`;
101
127
  case "auth_denied":
102
128
  return `auth denied${entry.reason ? `: ${String(entry.reason)}` : ""}`;
103
129
  case "mcp_request_error":
@@ -121,8 +147,8 @@ function toolTarget(entry, tool) {
121
147
  }
122
148
  function toolResult(entry, tool, options) {
123
149
  if (entry.running === true) {
124
- const processSessionId = entry.processSessionId;
125
- return style("yellow", processSessionId === undefined ? "running" : `running process:${String(processSessionId)}`, options);
150
+ const processId = entry.processId ?? entry.processSessionId;
151
+ return style("yellow", processId === undefined ? "running" : `running process:${String(processId)}`, options);
126
152
  }
127
153
  const exitCode = numberField(entry.exitCode) ?? exitCodeFromError(entry.error);
128
154
  if (isShellTool(tool)) {
@@ -161,6 +187,42 @@ function formatHttpMessage(entry, options) {
161
187
  const statusStyle = status !== undefined && status >= 400 ? "red" : "green";
162
188
  return `http ${method} ${path} -> ${style(statusStyle, statusText, options)}`;
163
189
  }
190
+ function formatMcpRequestMessage(entry) {
191
+ const method = stringField(entry.rpcMethod) ?? stringField(entry.httpMethod) ?? "request";
192
+ const target = stringField(entry.rpcTarget);
193
+ return target ? `mcp ${method} ${target}` : `mcp ${method}`;
194
+ }
195
+ function formatAppTemplateMessage(entry, options, failed) {
196
+ const requestedUri = stringField(entry.requestedUri) ?? "unknown";
197
+ const compatibility = stringField(entry.compatibility) ?? "unknown";
198
+ if (failed) {
199
+ const error = stringField(entry.error);
200
+ return `app template ${compatibility} ${requestedUri} -> ${style("red", error ? `error: ${error}` : "error", options)}`;
201
+ }
202
+ const currentUri = stringField(entry.currentUri);
203
+ const target = compatibility === "current" || !currentUri
204
+ ? requestedUri
205
+ : `${requestedUri} => ${currentUri}`;
206
+ return `app template ${compatibility} ${target} -> ${style("green", "ok", options)}`;
207
+ }
208
+ function formatPrettySource(source, options) {
209
+ const separator = source.lastIndexOf("/");
210
+ if (separator <= 0 || separator === source.length - 1) {
211
+ return style(["cyan", "underline"], source, options);
212
+ }
213
+ const project = source.slice(0, separator);
214
+ const workspace = source.slice(separator + 1);
215
+ const projectColor = WORKSPACE_PROJECT_COLORS[stableColorIndex(project)];
216
+ return `${style([projectColor, "bold"], project, options)}/${style(["cyan", "underline"], workspace, options)}`;
217
+ }
218
+ function stableColorIndex(value) {
219
+ let hash = 2166136261;
220
+ for (let index = 0; index < value.length; index += 1) {
221
+ hash ^= value.charCodeAt(index);
222
+ hash = Math.imul(hash, 16777619);
223
+ }
224
+ return (hash >>> 0) % WORKSPACE_PROJECT_COLORS.length;
225
+ }
164
226
  function formatGenericMessage(entry) {
165
227
  const event = String(entry.event ?? "log");
166
228
  const detail = [entry.reason, entry.error]
@@ -181,6 +243,11 @@ function logLevel(value) {
181
243
  function stringField(value) {
182
244
  return typeof value === "string" && value.length > 0 ? value : undefined;
183
245
  }
246
+ function transportSessionPrefix(entry) {
247
+ return stringField(entry.transportSessionIdPrefix)
248
+ ?? stringField(entry.sessionIdPrefix)
249
+ ?? stringField(entry.session);
250
+ }
184
251
  function numberField(value) {
185
252
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
186
253
  }
@@ -34,7 +34,7 @@ export function buildToolDescriptions(config) {
34
34
  rename: `Rename or move one file or directory inside an open workspace or the OS temp directory without overwriting an existing destination. Source and destination must both remain inside the permitted file roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
35
35
  delete: `Delete one file or directory inside an open workspace or the OS temp directory. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
36
36
  applyPatch: `Apply one Codex-style patch inside an open workspace or the OS temp directory. Supports adding, overwriting, updating, deleting, and moving files. Workspace paths must remain relative; absolute paths are accepted only inside the OS temp directory. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
37
- shell: `Run a shell command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. ForgeRelay waits up to 300 seconds for bash, then returns a running process session without killing it; use ${toolNames.writeStdin} to poll, keep waiting, interact, or send Ctrl-C. Completed background commands are also reported with a later tool result for the same workspaceId. ${shellMutationPolicy} Call ${toolNames.openWorkspace} first and pass workspaceId. This capability should only be exposed behind strong authentication.`,
37
+ shell: `Run a shell command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. ForgeRelay waits up to 300 seconds for bash, then returns a running process with a processId without killing it; use ${toolNames.writeStdin} with that processId to poll, keep waiting, interact, or send Ctrl-C. Completed background commands are also reported with a later tool result for the same workspaceId. ${shellMutationPolicy} Call ${toolNames.openWorkspace} first and pass workspaceId. This capability should only be exposed behind strong authentication.`,
38
38
  shellCommand: "Shell command to run with the local user's authority.",
39
39
  };
40
40
  }
@@ -62,9 +62,9 @@ function toolSurfaceInstructions(config) {
62
62
  return `In codex tool mode, workspace file and command operations use ${toolNames.read}, ${toolNames.rename}, ${toolNames.delete}, apply_patch, exec_command, and ${toolNames.writeStdin}.`;
63
63
  }
64
64
  if (config.toolMode === "full") {
65
- return `In full tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are available alongside the core workspace tools. ${toolNames.writeStdin} is available for running bash sessions.`;
65
+ return `In full tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are available alongside the core workspace tools. ${toolNames.writeStdin} is available for running bash processes.`;
66
66
  }
67
- return `In minimal tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are disabled; the core workspace tools remain available, including ${toolNames.writeStdin} for running bash sessions.`;
67
+ return `In minimal tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are disabled; the core workspace tools remain available, including ${toolNames.writeStdin} for running bash processes.`;
68
68
  }
69
69
  function selectedWorkflowInstructions(config) {
70
70
  if (config.workflowInstructions === false)
@@ -80,7 +80,7 @@ function defaultWorkflowInstructions(config) {
80
80
  const inspection = config.toolMode === "full"
81
81
  ? `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection.`
82
82
  : `Use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection.`;
83
- return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, git inspection, package scripts, generators, formatters, and commands that are better executed by the shell. If ${toolNames.shell} returns a running session, use ${toolNames.writeStdin} only when you need to poll, wait, interact, or interrupt it; otherwise you may continue other work and consume its completion notice from a later tool result.`);
83
+ return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, git inspection, package scripts, generators, formatters, and commands that are better executed by the shell. If ${toolNames.shell} returns a running process with a processId, use ${toolNames.writeStdin} only when you need to poll, wait, interact, or interrupt it; otherwise you may continue other work and consume its completion notice from a later tool result.`);
84
84
  }
85
85
  function joinInstructions(...parts) {
86
86
  return parts
@@ -0,0 +1,45 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ export const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html";
4
+ export const WORKSPACE_APP_LEGACY_URI = "ui://forgerelay/workspace-app.html";
5
+ export const WORKSPACE_APP_URI_TEMPLATE = "ui://forgerelay/workspace-app-{revision}.html";
6
+ export function workspaceAppUriForRevision(revision) {
7
+ return `ui://forgerelay/workspace-app-${encodeURIComponent(revision)}.html`;
8
+ }
9
+ export function readWorkspaceAppManifestEntry(manifestUrl) {
10
+ const manifest = JSON.parse(readFileSync(manifestUrl, "utf8"));
11
+ const entry = manifest[WORKSPACE_APP_MANIFEST_ENTRY];
12
+ if (!entry?.file) {
13
+ throw new Error(`Missing ${WORKSPACE_APP_MANIFEST_ENTRY} in UI manifest.`);
14
+ }
15
+ return entry;
16
+ }
17
+ export function workspaceAppBundleRevision(entry, buildDirectoryUrl) {
18
+ const hash = createHash("sha256");
19
+ const assetPaths = [entry.file, ...(entry.css ?? [])];
20
+ for (const assetPath of assetPaths) {
21
+ hash.update(assetPath);
22
+ hash.update("\0");
23
+ hash.update(readFileSync(new URL(assetPath, buildDirectoryUrl)));
24
+ hash.update("\0");
25
+ }
26
+ return hash.digest("hex").slice(0, 12);
27
+ }
28
+ export function resolveWorkspaceAppIdentity(options) {
29
+ try {
30
+ const entry = readWorkspaceAppManifestEntry(options.manifestUrl);
31
+ const revision = workspaceAppBundleRevision(entry, options.buildDirectoryUrl);
32
+ return {
33
+ revision,
34
+ uri: workspaceAppUriForRevision(revision),
35
+ source: "bundle",
36
+ };
37
+ }
38
+ catch {
39
+ return {
40
+ revision: options.fallbackRevision,
41
+ uri: workspaceAppUriForRevision(options.fallbackRevision),
42
+ source: "fallback",
43
+ };
44
+ }
45
+ }
@@ -1,56 +1,58 @@
1
- export class McpSessionRegistry {
2
- sessions = new Map();
1
+ export class McpTransportRegistry {
2
+ transports = new Map();
3
3
  now;
4
4
  constructor(options = {}) {
5
5
  this.now = options.now ?? Date.now;
6
6
  }
7
7
  get size() {
8
- return this.sessions.size;
8
+ return this.transports.size;
9
9
  }
10
- register(sessionId, transport) {
11
- this.sessions.set(sessionId, {
10
+ register(transportSessionId, transport) {
11
+ this.transports.set(transportSessionId, {
12
12
  transport,
13
13
  lastActivityAt: this.now(),
14
14
  });
15
15
  }
16
- get(sessionId) {
17
- const entry = this.sessions.get(sessionId);
16
+ get(transportSessionId) {
17
+ const entry = this.transports.get(transportSessionId);
18
18
  if (!entry)
19
19
  return undefined;
20
20
  entry.lastActivityAt = this.now();
21
21
  return entry.transport;
22
22
  }
23
- remove(sessionId) {
24
- return this.sessions.delete(sessionId);
23
+ remove(transportSessionId) {
24
+ return this.transports.delete(transportSessionId);
25
25
  }
26
26
  async closeIdle(idleTimeoutMs) {
27
27
  const cutoff = this.now() - idleTimeoutMs;
28
- const idleSessions = [];
29
- for (const [sessionId, entry] of this.sessions) {
28
+ const idleTransports = [];
29
+ for (const [transportSessionId, entry] of this.transports) {
30
30
  if (entry.lastActivityAt > cutoff)
31
31
  continue;
32
- this.sessions.delete(sessionId);
33
- idleSessions.push({ sessionId, transport: entry.transport });
32
+ this.transports.delete(transportSessionId);
33
+ idleTransports.push({ transportSessionId, transport: entry.transport });
34
34
  }
35
- return closeSessions(idleSessions);
35
+ return closeTransports(idleTransports);
36
36
  }
37
37
  async closeAll() {
38
- const sessions = Array.from(this.sessions, ([sessionId, entry]) => ({
39
- sessionId,
38
+ const transports = Array.from(this.transports, ([transportSessionId, entry]) => ({
39
+ transportSessionId,
40
40
  transport: entry.transport,
41
41
  }));
42
- this.sessions.clear();
43
- return closeSessions(sessions);
42
+ this.transports.clear();
43
+ return closeTransports(transports);
44
44
  }
45
45
  }
46
- async function closeSessions(sessions) {
47
- return Promise.all(sessions.map(async ({ sessionId, transport }) => {
46
+ async function closeTransports(transports) {
47
+ return Promise.all(transports.map(async ({ transportSessionId, transport }) => {
48
48
  try {
49
49
  await transport.close();
50
- return { sessionId };
50
+ return { transportSessionId };
51
51
  }
52
52
  catch (error) {
53
- return { sessionId, error };
53
+ return { transportSessionId, error };
54
54
  }
55
55
  }));
56
56
  }
57
+ /** @deprecated Use McpTransportRegistry. */
58
+ export { McpTransportRegistry as McpSessionRegistry };