@akira-tl/forgerelay 0.2.5 → 0.3.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.
package/dist/logger.js CHANGED
@@ -47,23 +47,17 @@ export function logEvent(config, level, event, fields = {}) {
47
47
  console.log(line);
48
48
  }
49
49
  }
50
- export function requestIp(req, trustProxy) {
51
- if (trustProxy) {
52
- const cfConnectingIp = firstHeaderValue(req.header("cf-connecting-ip"));
53
- if (cfConnectingIp)
54
- return cfConnectingIp;
55
- const forwardedFor = firstHeaderValue(req.header("x-forwarded-for"));
56
- if (forwardedFor)
57
- return forwardedFor;
58
- }
50
+ export function requestIp(req) {
59
51
  return req.ip ?? req.socket.remoteAddress;
60
52
  }
61
53
  export function requestPath(req) {
62
54
  return req.path || req.url.split("?")[0] || req.url;
63
55
  }
64
- export function sessionIdPrefix(sessionId) {
65
- return sessionId ? sessionId.slice(0, 8) : undefined;
56
+ export function transportSessionIdPrefix(transportSessionId) {
57
+ return transportSessionId ? transportSessionId.slice(0, 8) : undefined;
66
58
  }
59
+ /** @deprecated Use transportSessionIdPrefix. */
60
+ export const sessionIdPrefix = transportSessionIdPrefix;
67
61
  export function workspaceLogLabel(root, workspaceId) {
68
62
  const shortWorkspaceId = workspaceId.startsWith("ws_")
69
63
  ? `ws_${workspaceId.slice(3, 11)}`
@@ -78,21 +72,20 @@ export function formatPrettyLogEntry(entry, options = {}) {
78
72
  const level = logLevel(entry.level);
79
73
  const time = formatTimestamp(entry.ts);
80
74
  const source = stringField(entry.workspace) ?? stringField(entry.workspaceId) ?? "forgerelay";
81
- const session = level === "debug"
82
- ? stringField(entry.session) ?? stringField(entry.sessionIdPrefix)
75
+ const transportSession = level === "debug"
76
+ ? stringField(entry.transportSessionIdPrefix)
77
+ ?? stringField(entry.session)
78
+ ?? stringField(entry.sessionIdPrefix)
83
79
  : undefined;
84
80
  const prefix = [
85
81
  style("gray", time, options),
86
82
  `[${style(LEVEL_STYLE[level], level.toUpperCase(), options)}]`,
87
83
  formatPrettySource(source, options),
88
- session ? style("gray", `session:${session}`, options) : undefined,
84
+ transportSession ? style("gray", `transport:${transportSession}`, options) : undefined,
89
85
  style("gray", "|", options),
90
86
  ].filter((value) => Boolean(value)).join(" ");
91
87
  return `${prefix} ${formatPrettyMessage(entry, options)}`;
92
88
  }
93
- function firstHeaderValue(value) {
94
- return value?.split(",")[0]?.trim() || undefined;
95
- }
96
89
  function formatPrettyMessage(entry, options) {
97
90
  switch (String(entry.event)) {
98
91
  case "tool_call":
@@ -108,14 +101,18 @@ function formatPrettyMessage(entry, options) {
108
101
  return formatAppTemplateMessage(entry, options, false);
109
102
  case "mcp_app_template_read_failed":
110
103
  return formatAppTemplateMessage(entry, options, true);
104
+ case "mcp_transport_session_created":
111
105
  case "mcp_session_created":
112
- return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} created`;
106
+ return `transport session ${transportSessionPrefix(entry) ?? "unknown"} created`;
107
+ case "mcp_transport_session_closed":
113
108
  case "mcp_session_closed":
114
- return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} closed`;
109
+ return `transport session ${transportSessionPrefix(entry) ?? "unknown"} closed`;
110
+ case "mcp_transport_sessions_closed":
115
111
  case "mcp_sessions_closed":
116
- return `${numberField(entry.count) ?? 0} sessions closed`;
112
+ return `${numberField(entry.count) ?? 0} transport sessions closed`;
113
+ case "mcp_transport_session_close_failed":
117
114
  case "mcp_session_close_failed":
118
- return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} close -> ${style("red", "error", options)}`;
115
+ return `transport session ${transportSessionPrefix(entry) ?? "unknown"} close -> ${style("red", "error", options)}`;
119
116
  case "auth_denied":
120
117
  return `auth denied${entry.reason ? `: ${String(entry.reason)}` : ""}`;
121
118
  case "mcp_request_error":
@@ -139,8 +136,8 @@ function toolTarget(entry, tool) {
139
136
  }
140
137
  function toolResult(entry, tool, options) {
141
138
  if (entry.running === true) {
142
- const processSessionId = entry.processSessionId;
143
- return style("yellow", processSessionId === undefined ? "running" : `running process:${String(processSessionId)}`, options);
139
+ const processId = entry.processId ?? entry.processSessionId;
140
+ return style("yellow", processId === undefined ? "running" : `running process:${String(processId)}`, options);
144
141
  }
145
142
  const exitCode = numberField(entry.exitCode) ?? exitCodeFromError(entry.error);
146
143
  if (isShellTool(tool)) {
@@ -235,6 +232,11 @@ function logLevel(value) {
235
232
  function stringField(value) {
236
233
  return typeof value === "string" && value.length > 0 ? value : undefined;
237
234
  }
235
+ function transportSessionPrefix(entry) {
236
+ return stringField(entry.transportSessionIdPrefix)
237
+ ?? stringField(entry.sessionIdPrefix)
238
+ ?? stringField(entry.session);
239
+ }
238
240
  function numberField(value) {
239
241
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
240
242
  }
@@ -14,57 +14,42 @@ export const toolNames = {
14
14
  writeStdin: "write_stdin",
15
15
  };
16
16
  export function buildShellMutationPolicy() {
17
- return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
17
+ return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. They may also perform external device or hardware mutations when the user's current request explicitly asks for the actual device-changing operation, including firmware flashing or equivalent persistent device updates; do not infer such authorization from a check, audit, probe, backup, verification, dry-run, or build-only request. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
18
18
  }
19
- export function buildServerInstructions(config, context = {}) {
20
- return joinInstructions(capabilityContractInstructions(config, context), selectedWorkflowInstructions(config), config.appendInstructions);
19
+ export function buildServerInstructions(config) {
20
+ return joinInstructions(capabilityContractInstructions(config), selectedWorkflowInstructions(config), config.appendInstructions);
21
21
  }
22
22
  export function buildToolDescriptions(config) {
23
23
  const skillCapability = config.skillsEnabled
24
- ? " Advertised skill paths may be outside the workspace; only advertised SKILL.md files and files under already-loaded skill directories are readable."
24
+ ? " Advertised skill paths may also be outside the workspace."
25
25
  : "";
26
26
  const shellSurface = config.toolMode === "minimal"
27
27
  ? ` In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled, so shell commands may be used for equivalent search and directory inspection.`
28
28
  : "";
29
- const shellMutationPolicy = buildShellMutationPolicy();
30
29
  return {
31
- read: `Read a file inside an open workspace or the OS temp directory. Instruction files returned by ${toolNames.openWorkspace} and advertised skill files are also readable when applicable.${skillCapability} Call ${toolNames.openWorkspace} first and pass workspaceId.`,
30
+ read: `Read a file inside an open workspace or the OS temp directory. Instruction files and advertised capability guides returned by ${toolNames.openWorkspace} are also readable when applicable.${skillCapability} Only advertised entry files and files under already-loaded advertised directories are readable outside the normal roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
32
31
  write: `Create or completely overwrite a file inside an open workspace or the OS temp directory. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
33
32
  edit: `Edit one file inside an open workspace or the OS temp directory by replacing exact text blocks. Each oldText must match a unique, non-overlapping region of the original file. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
34
33
  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
34
  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
35
  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.`,
36
+ 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, then returns a processId for a still-running command; use ${toolNames.writeStdin} to poll, interact, wait, or send Ctrl-C. Completed background commands may be reported later for the same workspaceId. Call ${toolNames.openWorkspace} first and pass workspaceId. Expose this capability only behind strong authentication.`,
38
37
  shellCommand: "Shell command to run with the local user's authority.",
39
38
  };
40
39
  }
41
- function capabilityContractInstructions(config, context) {
42
- const workspaceLifecycle = config.toolMode === "codex"
43
- ? `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Keep the workspaceId returned for this conversation stable. Different conversations normally receive separate logical workspaceIds even when they point at the same physical checkout or worktree; pass an existing workspaceId to ${toolNames.openWorkspace} only when the user wants to resume that logical workspace in this conversation. Only request a new logical workspace when the user explicitly asks. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`
44
- : `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Keep the workspaceId returned for this conversation stable for later file, search, edit, write, rename, delete, show-changes, shell, and process-polling tools. Different conversations normally receive separate logical workspaceIds even when they point at the same physical checkout or worktree; pass an existing workspaceId to ${toolNames.openWorkspace} only when the user wants to resume that logical workspace in this conversation. Only request a new logical workspace when the user explicitly asks. If ${toolNames.openWorkspace} reports logical workspaces idle for more than two days, tell the user each workspaceId and let them decide whether to resume it or explicitly clean it up with ${toolNames.closeWorkspace}; do not close it automatically. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`;
45
- const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it.`;
40
+ function capabilityContractInstructions(config) {
41
+ const staleWorkspacePolicy = config.toolMode === "codex"
42
+ ? ""
43
+ : ` If ${toolNames.openWorkspace} reports logical workspaces idle for more than two days, let the user choose whether to resume or close them with ${toolNames.closeWorkspace}; never close them automatically.`;
44
+ const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse the workspaceId returned by ${toolNames.openWorkspace} for this conversation; resume another logical workspaceId only when the user wants that workspace, and request a new logical workspace only when explicitly asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} releases a logical workspace; ${toolNames.closeWorktree} finalizes a managed worktree. Read the managed-worktrees capability guide for advanced worktree lifecycle and failure semantics.`;
45
+ const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
46
+ const capabilityGuides = `When ${toolNames.openWorkspace} returns capability guides, use ${toolNames.read} to load only a task-relevant guide; do not preload all guides.`;
46
47
  const skills = config.skillsEnabled
47
- ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories.`
48
+ ? `When a task matches an available skill from ${toolNames.openWorkspace}, read its advertised path before proceeding. Outside normal file roots, ${toolNames.read} permits only advertised entry files and files under already-loaded advertised directories.`
48
49
  : "";
49
- const toolSurface = toolSurfaceInstructions(config);
50
50
  const shellMutationPolicy = buildShellMutationPolicy();
51
51
  const hooks = "When a ForgeRelay tool result reports Hook results, tell the user which meaningful hooks ran and whether they passed or blocked the operation. Do not claim the requested operation succeeded when a blocking hook prevented it.";
52
- const artifact = config.artifactsEnabled && context.artifactDownloadSupported
53
- ? "When the user supplies or generates a file that is not present on the ForgeRelay host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs."
54
- : "";
55
- const showChanges = config.widgets === "changes"
56
- ? "If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs."
57
- : "";
58
- return joinInstructions(workspaceLifecycle, agents, skills, toolSurface, shellMutationPolicy, hooks, artifact, showChanges);
59
- }
60
- function toolSurfaceInstructions(config) {
61
- if (config.toolMode === "codex") {
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
- }
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.`;
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.`;
52
+ return joinInstructions(workspaceLifecycle, agents, capabilityGuides, skills, shellMutationPolicy, hooks);
68
53
  }
69
54
  function selectedWorkflowInstructions(config) {
70
55
  if (config.workflowInstructions === false)
@@ -80,7 +65,7 @@ function defaultWorkflowInstructions(config) {
80
65
  const inspection = config.toolMode === "full"
81
66
  ? `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection.`
82
67
  : `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.`);
68
+ 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
69
  }
85
70
  function joinInstructions(...parts) {
86
71
  return parts
@@ -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 };