@akira-tl/forgerelay 0.2.5 → 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,14 @@ 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
+
7
15
  ## [0.2.5] - 2026-08-10
8
16
 
9
17
  ### Changed
@@ -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
@@ -61,9 +61,11 @@ export function requestIp(req, trustProxy) {
61
61
  export function requestPath(req) {
62
62
  return req.path || req.url.split("?")[0] || req.url;
63
63
  }
64
- export function sessionIdPrefix(sessionId) {
65
- return sessionId ? sessionId.slice(0, 8) : undefined;
64
+ export function transportSessionIdPrefix(transportSessionId) {
65
+ return transportSessionId ? transportSessionId.slice(0, 8) : undefined;
66
66
  }
67
+ /** @deprecated Use transportSessionIdPrefix. */
68
+ export const sessionIdPrefix = transportSessionIdPrefix;
67
69
  export function workspaceLogLabel(root, workspaceId) {
68
70
  const shortWorkspaceId = workspaceId.startsWith("ws_")
69
71
  ? `ws_${workspaceId.slice(3, 11)}`
@@ -78,14 +80,16 @@ export function formatPrettyLogEntry(entry, options = {}) {
78
80
  const level = logLevel(entry.level);
79
81
  const time = formatTimestamp(entry.ts);
80
82
  const source = stringField(entry.workspace) ?? stringField(entry.workspaceId) ?? "forgerelay";
81
- const session = level === "debug"
82
- ? stringField(entry.session) ?? stringField(entry.sessionIdPrefix)
83
+ const transportSession = level === "debug"
84
+ ? stringField(entry.transportSessionIdPrefix)
85
+ ?? stringField(entry.session)
86
+ ?? stringField(entry.sessionIdPrefix)
83
87
  : undefined;
84
88
  const prefix = [
85
89
  style("gray", time, options),
86
90
  `[${style(LEVEL_STYLE[level], level.toUpperCase(), options)}]`,
87
91
  formatPrettySource(source, options),
88
- session ? style("gray", `session:${session}`, options) : undefined,
92
+ transportSession ? style("gray", `transport:${transportSession}`, options) : undefined,
89
93
  style("gray", "|", options),
90
94
  ].filter((value) => Boolean(value)).join(" ");
91
95
  return `${prefix} ${formatPrettyMessage(entry, options)}`;
@@ -108,14 +112,18 @@ function formatPrettyMessage(entry, options) {
108
112
  return formatAppTemplateMessage(entry, options, false);
109
113
  case "mcp_app_template_read_failed":
110
114
  return formatAppTemplateMessage(entry, options, true);
115
+ case "mcp_transport_session_created":
111
116
  case "mcp_session_created":
112
- return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} created`;
117
+ return `transport session ${transportSessionPrefix(entry) ?? "unknown"} created`;
118
+ case "mcp_transport_session_closed":
113
119
  case "mcp_session_closed":
114
- return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} closed`;
120
+ return `transport session ${transportSessionPrefix(entry) ?? "unknown"} closed`;
121
+ case "mcp_transport_sessions_closed":
115
122
  case "mcp_sessions_closed":
116
- return `${numberField(entry.count) ?? 0} sessions closed`;
123
+ return `${numberField(entry.count) ?? 0} transport sessions closed`;
124
+ case "mcp_transport_session_close_failed":
117
125
  case "mcp_session_close_failed":
118
- return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} close -> ${style("red", "error", options)}`;
126
+ return `transport session ${transportSessionPrefix(entry) ?? "unknown"} close -> ${style("red", "error", options)}`;
119
127
  case "auth_denied":
120
128
  return `auth denied${entry.reason ? `: ${String(entry.reason)}` : ""}`;
121
129
  case "mcp_request_error":
@@ -139,8 +147,8 @@ function toolTarget(entry, tool) {
139
147
  }
140
148
  function toolResult(entry, tool, options) {
141
149
  if (entry.running === true) {
142
- const processSessionId = entry.processSessionId;
143
- 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);
144
152
  }
145
153
  const exitCode = numberField(entry.exitCode) ?? exitCodeFromError(entry.error);
146
154
  if (isShellTool(tool)) {
@@ -235,6 +243,11 @@ function logLevel(value) {
235
243
  function stringField(value) {
236
244
  return typeof value === "string" && value.length > 0 ? value : undefined;
237
245
  }
246
+ function transportSessionPrefix(entry) {
247
+ return stringField(entry.transportSessionIdPrefix)
248
+ ?? stringField(entry.sessionIdPrefix)
249
+ ?? stringField(entry.session);
250
+ }
238
251
  function numberField(value) {
239
252
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
240
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
@@ -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 };
@@ -8,7 +8,7 @@ const MAX_COMMAND_YIELD_MS = 300_000;
8
8
  const MAX_POLL_YIELD_MS = 300_000;
9
9
  const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
10
10
  const DEFAULT_BUFFER_CHARACTERS = 1_000_000;
11
- const COMPLETED_SESSION_TTL_MS = 24 * 60 * 60 * 1_000;
11
+ const COMPLETED_PROCESS_TTL_MS = 24 * 60 * 60 * 1_000;
12
12
  const DEFAULT_COLUMNS = 80;
13
13
  const DEFAULT_ROWS = 24;
14
14
  function boundedInteger(value, fallback, maximum) {
@@ -27,6 +27,19 @@ function terminalSize(value, fallback) {
27
27
  }
28
28
  return value;
29
29
  }
30
+ export function resolveProcessId(processId, legacySessionId) {
31
+ if (processId !== undefined && legacySessionId !== undefined && processId !== legacySessionId) {
32
+ throw new Error("processId and deprecated sessionId must identify the same process when both are provided.");
33
+ }
34
+ const resolved = processId ?? legacySessionId;
35
+ if (resolved === undefined) {
36
+ throw new Error("A processId is required. Deprecated sessionId remains accepted for compatibility.");
37
+ }
38
+ if (!Number.isInteger(resolved) || resolved < 1) {
39
+ throw new Error("processId must be a positive integer.");
40
+ }
41
+ return resolved;
42
+ }
30
43
  function processEnvironment(input) {
31
44
  return {
32
45
  ...Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined && (input?.codexCi || entry[0] !== "CODEX_CI"))),
@@ -129,110 +142,120 @@ function truncateOutput(output, maxCharacters) {
129
142
  truncated: true,
130
143
  };
131
144
  }
132
- export class ProcessSessionManager {
133
- sessions = new Map();
145
+ export class ProcessManager {
146
+ processes = new Map();
134
147
  completedByWorkspace = new Map();
135
148
  maxBufferCharacters;
136
- completedSessionTtlMs;
149
+ completedProcessTtlMs;
137
150
  maxStartYieldMs;
138
- nextSessionId = 1;
151
+ monotonicNow;
152
+ nextProcessId = 1;
139
153
  constructor(options = {}) {
140
154
  this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
141
- this.completedSessionTtlMs = options.completedSessionTtlMs ?? COMPLETED_SESSION_TTL_MS;
155
+ this.completedProcessTtlMs = options.completedProcessTtlMs
156
+ ?? options.completedSessionTtlMs
157
+ ?? COMPLETED_PROCESS_TTL_MS;
142
158
  this.maxStartYieldMs = options.maxStartYieldMs ?? MAX_START_YIELD_MS;
159
+ this.monotonicNow = options.monotonicNow ?? (() => performance.now());
143
160
  }
144
161
  async start(input) {
145
- const session = this.createSession(input);
146
- this.sessions.set(session.id, session);
162
+ const processEntry = this.createProcess(input);
163
+ this.processes.set(processEntry.id, processEntry);
147
164
  try {
148
165
  if (input.tty && process.platform !== "win32")
149
- await this.startPty(session, input);
166
+ await this.startPty(processEntry, input);
150
167
  else
151
- this.startPipe(session, input);
168
+ this.startPipe(processEntry, input);
152
169
  }
153
170
  catch (error) {
154
- this.sessions.delete(session.id);
171
+ this.processes.delete(processEntry.id);
155
172
  throw error;
156
173
  }
157
174
  const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, this.maxStartYieldMs);
158
- await this.waitForExit(session, yieldTimeMs);
159
- if (session.running)
160
- session.background = true;
161
- const snapshot = this.consume(session, input.maxOutputTokens);
175
+ await this.waitForExit(processEntry, yieldTimeMs);
176
+ if (processEntry.running)
177
+ processEntry.background = true;
178
+ const snapshot = this.consume(processEntry, input.maxOutputTokens);
162
179
  if (!snapshot.running)
163
- this.removeSession(session.id);
180
+ this.removeProcess(processEntry.id);
164
181
  return snapshot;
165
182
  }
166
183
  async write(input) {
167
- const session = this.getOwnedSession(input.workspaceId, input.sessionId);
184
+ const processId = resolveProcessId(input.processId, input.sessionId);
185
+ const processEntry = this.getOwnedProcess(input.workspaceId, processId);
168
186
  const chars = input.chars ?? "";
169
187
  const interactionRequested = chars.length > 0 || input.columns !== undefined || input.rows !== undefined;
170
188
  if (input.columns !== undefined || input.rows !== undefined) {
171
- session.columns = terminalSize(input.columns, session.columns);
172
- session.rows = terminalSize(input.rows, session.rows);
173
- if (!session.process?.resize) {
174
- throw new Error(`Process session ${session.id} is not a PTY and cannot be resized.`);
189
+ processEntry.columns = terminalSize(input.columns, processEntry.columns);
190
+ processEntry.rows = terminalSize(input.rows, processEntry.rows);
191
+ if (!processEntry.process?.resize) {
192
+ throw new Error(`Process ${processEntry.id} is not a PTY and cannot be resized.`);
175
193
  }
176
- session.process.resize(session.columns, session.rows);
194
+ processEntry.process.resize(processEntry.columns, processEntry.rows);
177
195
  }
178
- const interruptRequested = chars.includes("\u0003") && session.running;
196
+ const interruptRequested = chars.includes("\u0003") && processEntry.running;
179
197
  if (interruptRequested) {
180
- session.process?.kill("SIGINT");
198
+ processEntry.process?.kill("SIGINT");
181
199
  }
182
200
  const writableChars = chars.replaceAll("\u0003", "");
183
- if (writableChars && session.running)
184
- session.process?.write(writableChars);
185
- if ((interactionRequested || !session.buffer.hasOutput()) && session.running) {
201
+ if (writableChars && processEntry.running)
202
+ processEntry.process?.write(writableChars);
203
+ if ((interactionRequested || !processEntry.buffer.hasOutput()) && processEntry.running) {
186
204
  const fallback = interactionRequested ? DEFAULT_INTERACTIVE_YIELD_MS : DEFAULT_POLL_YIELD_MS;
187
205
  const maximum = interactionRequested ? MAX_COMMAND_YIELD_MS : MAX_POLL_YIELD_MS;
188
206
  const yieldTimeMs = boundedInteger(input.yieldTimeMs, fallback, maximum);
189
- await this.waitForExit(session, yieldTimeMs);
207
+ await this.waitForExit(processEntry, yieldTimeMs);
190
208
  }
191
- const snapshot = this.consume(session, input.maxOutputTokens);
192
- if (!session.running)
193
- this.removeSession(session.id);
209
+ const snapshot = this.consume(processEntry, input.maxOutputTokens);
210
+ if (!processEntry.running)
211
+ this.removeProcess(processEntry.id);
194
212
  return snapshot;
195
213
  }
196
214
  activeWorkspaceIds() {
197
- return new Set([...this.sessions.values()].map((session) => session.workspaceId));
215
+ return new Set([...this.processes.values()].map((processEntry) => processEntry.workspaceId));
198
216
  }
199
- takeCompleted(workspaceId, maxOutputTokens, excludeSessionId) {
200
- const sessionIds = this.completedByWorkspace.get(workspaceId) ?? [];
201
- if (sessionIds.length === 0)
217
+ takeCompleted(workspaceId, maxOutputTokens, excludeProcessId) {
218
+ const processIds = this.completedByWorkspace.get(workspaceId) ?? [];
219
+ if (processIds.length === 0)
202
220
  return [];
203
221
  const completed = [];
204
- for (const sessionId of sessionIds) {
205
- if (sessionId === excludeSessionId)
222
+ for (const processId of processIds) {
223
+ if (processId === excludeProcessId)
206
224
  continue;
207
- const session = this.sessions.get(sessionId);
208
- if (!session || session.running)
225
+ const processEntry = this.processes.get(processId);
226
+ if (!processEntry || processEntry.running)
209
227
  continue;
210
- const snapshot = this.consume(session, maxOutputTokens);
211
- completed.push({ ...snapshot, sessionId: session.id, command: session.command });
212
- this.removeSession(session.id);
228
+ const snapshot = this.consume(processEntry, maxOutputTokens);
229
+ completed.push({
230
+ ...snapshot,
231
+ processId: processEntry.id,
232
+ sessionId: processEntry.id,
233
+ command: processEntry.command,
234
+ });
235
+ this.removeProcess(processEntry.id);
213
236
  }
214
237
  return completed;
215
238
  }
216
- terminate(workspaceId, sessionId) {
217
- const session = this.getOwnedSession(workspaceId, sessionId);
218
- if (session.running)
219
- session.process?.kill("SIGTERM");
239
+ terminate(workspaceId, processId) {
240
+ const processEntry = this.getOwnedProcess(workspaceId, processId);
241
+ if (processEntry.running)
242
+ processEntry.process?.kill("SIGTERM");
220
243
  }
221
244
  shutdown() {
222
- for (const session of this.sessions.values()) {
223
- if (session.cleanupTimer)
224
- clearTimeout(session.cleanupTimer);
225
- if (session.running)
226
- session.process?.kill("SIGTERM");
245
+ for (const processEntry of this.processes.values()) {
246
+ if (processEntry.cleanupTimer)
247
+ clearTimeout(processEntry.cleanupTimer);
248
+ if (processEntry.running)
249
+ processEntry.process?.kill("SIGTERM");
227
250
  }
228
- this.sessions.clear();
251
+ this.processes.clear();
229
252
  this.completedByWorkspace.clear();
230
253
  }
231
- async waitForExit(session, yieldTimeMs) {
254
+ async waitForExit(processEntry, yieldTimeMs) {
232
255
  let timer;
233
256
  try {
234
257
  await Promise.race([
235
- session.exitPromise,
258
+ processEntry.exitPromise,
236
259
  new Promise((resolve) => {
237
260
  timer = setTimeout(resolve, yieldTimeMs);
238
261
  }),
@@ -243,16 +266,16 @@ export class ProcessSessionManager {
243
266
  clearTimeout(timer);
244
267
  }
245
268
  }
246
- createSession(input) {
269
+ createProcess(input) {
247
270
  let resolveExit = () => undefined;
248
271
  const exitPromise = new Promise((resolve) => {
249
272
  resolveExit = resolve;
250
273
  });
251
274
  return {
252
- id: this.nextSessionId++,
275
+ id: this.nextProcessId++,
253
276
  workspaceId: input.workspaceId,
254
277
  command: input.command,
255
- startedAt: Date.now(),
278
+ startedAtMonotonic: this.monotonicNow(),
256
279
  columns: terminalSize(input.columns, DEFAULT_COLUMNS),
257
280
  rows: terminalSize(input.rows, DEFAULT_ROWS),
258
281
  buffer: new HeadTailBuffer(this.maxBufferCharacters),
@@ -262,7 +285,7 @@ export class ProcessSessionManager {
262
285
  resolveExit,
263
286
  };
264
287
  }
265
- startPipe(session, input) {
288
+ startPipe(processEntry, input) {
266
289
  const shell = resolveShellCommand(input.command);
267
290
  const detached = process.platform !== "win32";
268
291
  const child = spawn(input.command, {
@@ -277,17 +300,17 @@ export class ProcessSessionManager {
277
300
  detached,
278
301
  shell: shell.executable,
279
302
  });
280
- session.process = {
303
+ processEntry.process = {
281
304
  write: (data) => child.stdin.write(data),
282
305
  kill: (signal = "SIGTERM") => terminateProcessTree(child, signal, detached),
283
306
  resize: input.tty ? () => undefined : undefined,
284
307
  };
285
- child.stdout.on("data", (data) => this.append(session, data.toString("utf8")));
286
- child.stderr.on("data", (data) => this.append(session, data.toString("utf8")));
287
- child.on("error", (error) => this.append(session, `${error.message}\n`));
288
- child.on("close", (code, signal) => this.finish(session, code ?? undefined, signal ?? undefined));
308
+ child.stdout.on("data", (data) => this.append(processEntry, data.toString("utf8")));
309
+ child.stderr.on("data", (data) => this.append(processEntry, data.toString("utf8")));
310
+ child.on("error", (error) => this.append(processEntry, `${error.message}\n`));
311
+ child.on("close", (code, signal) => this.finish(processEntry, code ?? undefined, signal ?? undefined));
289
312
  }
290
- async startPty(session, input) {
313
+ async startPty(processEntry, input) {
291
314
  let nodePty;
292
315
  try {
293
316
  nodePty = await import("node-pty");
@@ -306,80 +329,84 @@ export class ProcessSessionManager {
306
329
  codexCi: input.codexCi,
307
330
  }),
308
331
  name: "xterm-256color",
309
- cols: session.columns,
310
- rows: session.rows,
332
+ cols: processEntry.columns,
333
+ rows: processEntry.rows,
311
334
  });
312
335
  }
313
336
  catch (error) {
314
337
  throw error;
315
338
  }
316
- session.process = {
339
+ processEntry.process = {
317
340
  write: (data) => pty.write(data),
318
341
  kill: (signal) => pty.kill(signal),
319
342
  resize: (columns, rows) => pty.resize(columns, rows),
320
343
  };
321
- pty.onData((data) => this.append(session, data));
344
+ pty.onData((data) => this.append(processEntry, data));
322
345
  pty.onExit(({ exitCode, signal }) => {
323
- this.finish(session, exitCode, signal === 0 ? undefined : String(signal));
346
+ this.finish(processEntry, exitCode, signal === 0 ? undefined : String(signal));
324
347
  });
325
348
  }
326
- finish(session, exitCode, signal) {
327
- if (!session.running)
349
+ finish(processEntry, exitCode, signal) {
350
+ if (!processEntry.running)
328
351
  return;
329
- session.running = false;
330
- session.exitCode = exitCode;
331
- session.signal = signal;
332
- session.resolveExit();
333
- if (session.background) {
334
- const completed = this.completedByWorkspace.get(session.workspaceId) ?? [];
335
- if (!completed.includes(session.id)) {
336
- completed.push(session.id);
337
- this.completedByWorkspace.set(session.workspaceId, completed);
352
+ processEntry.running = false;
353
+ processEntry.exitCode = exitCode;
354
+ processEntry.signal = signal;
355
+ processEntry.resolveExit();
356
+ if (processEntry.background) {
357
+ const completed = this.completedByWorkspace.get(processEntry.workspaceId) ?? [];
358
+ if (!completed.includes(processEntry.id)) {
359
+ completed.push(processEntry.id);
360
+ this.completedByWorkspace.set(processEntry.workspaceId, completed);
338
361
  }
339
362
  }
340
- session.cleanupTimer = setTimeout(() => this.removeSession(session.id), this.completedSessionTtlMs);
341
- session.cleanupTimer.unref();
363
+ processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
364
+ processEntry.cleanupTimer.unref();
342
365
  }
343
- append(session, output) {
344
- session.buffer.append(output);
366
+ append(processEntry, output) {
367
+ processEntry.buffer.append(output);
345
368
  }
346
- consume(session, maxOutputTokens) {
369
+ consume(processEntry, maxOutputTokens) {
347
370
  const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000);
348
371
  const maxCharacters = Math.max(256, limit * 4);
349
- const buffered = session.buffer.drain(maxCharacters);
372
+ const buffered = processEntry.buffer.drain(maxCharacters);
373
+ const processId = processEntry.running ? processEntry.id : undefined;
350
374
  return {
351
- sessionId: session.running ? session.id : undefined,
375
+ processId,
376
+ sessionId: processId,
352
377
  output: buffered.output,
353
378
  outputTruncated: buffered.truncated,
354
- running: session.running,
355
- exitCode: session.exitCode,
356
- signal: session.signal,
357
- wallTimeMs: Date.now() - session.startedAt,
379
+ running: processEntry.running,
380
+ exitCode: processEntry.exitCode,
381
+ signal: processEntry.signal,
382
+ wallTimeMs: Math.max(0, Math.round(this.monotonicNow() - processEntry.startedAtMonotonic)),
358
383
  };
359
384
  }
360
- getOwnedSession(workspaceId, sessionId) {
361
- const session = this.sessions.get(sessionId);
362
- if (!session)
363
- throw new Error(`Unknown process session: ${sessionId}`);
364
- if (session.workspaceId !== workspaceId) {
365
- throw new Error(`Process session ${sessionId} does not belong to workspace ${workspaceId}.`);
385
+ getOwnedProcess(workspaceId, processId) {
386
+ const processEntry = this.processes.get(processId);
387
+ if (!processEntry)
388
+ throw new Error(`Unknown process: ${processId}`);
389
+ if (processEntry.workspaceId !== workspaceId) {
390
+ throw new Error(`Process ${processId} does not belong to workspace ${workspaceId}.`);
366
391
  }
367
- return session;
392
+ return processEntry;
368
393
  }
369
- removeSession(sessionId) {
370
- const session = this.sessions.get(sessionId);
371
- if (session?.cleanupTimer)
372
- clearTimeout(session.cleanupTimer);
373
- this.sessions.delete(sessionId);
374
- if (!session)
394
+ removeProcess(processId) {
395
+ const processEntry = this.processes.get(processId);
396
+ if (processEntry?.cleanupTimer)
397
+ clearTimeout(processEntry.cleanupTimer);
398
+ this.processes.delete(processId);
399
+ if (!processEntry)
375
400
  return;
376
- const completed = this.completedByWorkspace.get(session.workspaceId);
401
+ const completed = this.completedByWorkspace.get(processEntry.workspaceId);
377
402
  if (!completed)
378
403
  return;
379
- const remaining = completed.filter((id) => id !== sessionId);
404
+ const remaining = completed.filter((id) => id !== processId);
380
405
  if (remaining.length > 0)
381
- this.completedByWorkspace.set(session.workspaceId, remaining);
406
+ this.completedByWorkspace.set(processEntry.workspaceId, remaining);
382
407
  else
383
- this.completedByWorkspace.delete(session.workspaceId);
408
+ this.completedByWorkspace.delete(processEntry.workspaceId);
384
409
  }
385
410
  }
411
+ /** @deprecated Use ProcessManager. */
412
+ export { ProcessManager as ProcessSessionManager };
package/dist/server.js CHANGED
@@ -20,11 +20,11 @@ import { loadConfig } from "./config.js";
20
20
  import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
21
21
  import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
22
22
  import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
23
- import { logEvent, requestIp, requestPath, commandPreview, sessionIdPrefix, workspaceLogLabel, } from "./logger.js";
23
+ import { logEvent, requestIp, requestPath, commandPreview, transportSessionIdPrefix, workspaceLogLabel, } from "./logger.js";
24
24
  import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, writeFileTool, } from "./pi-tools.js";
25
25
  import { SingleUserOAuthProvider } from "./oauth-provider.js";
26
- import { McpSessionRegistry, } from "./mcp-sessions.js";
27
- import { ProcessSessionManager, } from "./process-sessions.js";
26
+ import { McpTransportRegistry, } from "./mcp-sessions.js";
27
+ import { ProcessManager, resolveProcessId, } from "./process-sessions.js";
28
28
  import { createReviewCheckpointManager } from "./review-checkpoints.js";
29
29
  import { openAiConversationScopeId } from "./request-meta.js";
30
30
  import { readWorkspaceAppManifestEntry, resolveWorkspaceAppIdentity, WORKSPACE_APP_LEGACY_URI, WORKSPACE_APP_URI_TEMPLATE, } from "./mcp-app-template.js";
@@ -34,11 +34,12 @@ import { createWorkspaceStore } from "./workspace-store.js";
34
34
  import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
35
35
  import { summarizeLocalAgentProfile } from "./local-agent-profiles.js";
36
36
  import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js";
37
- // MCP clients can reconnect without closing the previous transport. Bound stale
38
- // session retention so abandoned MCP servers do not accumulate for the life of the process.
39
- const MCP_SESSION_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
37
+ // Legacy MCP Streamable HTTP clients can reconnect without closing the previous
38
+ // transport. Bound stale transport-session retention so abandoned transports do
39
+ // not accumulate for the life of the process.
40
+ const MCP_TRANSPORT_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
40
41
  const FORGERELAY_VERSION = readForgeRelayVersion();
41
- const MCP_SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
42
+ const MCP_TRANSPORT_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
42
43
  const WRITE_TOOL_ANNOTATIONS = {
43
44
  readOnlyHint: false,
44
45
  destructiveHint: true,
@@ -81,7 +82,7 @@ function toolWidgetDescriptorMeta(config, kind) {
81
82
  },
82
83
  };
83
84
  }
84
- function workspaceLogContext(workspace, _sessionId) {
85
+ function workspaceLogContext(workspace, _transportSessionId) {
85
86
  return {
86
87
  workspaceId: workspace.id,
87
88
  workspace: workspaceLogLabel(workspace.root, workspace.id),
@@ -357,7 +358,7 @@ async function readWorkspaceAppResource(config, requestedUri, transportSessionId
357
358
  requestedUri,
358
359
  currentUri,
359
360
  compatibility,
360
- sessionIdPrefix: sessionIdPrefix(transportSessionId),
361
+ transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
361
362
  });
362
363
  return result;
363
364
  }
@@ -367,14 +368,14 @@ async function readWorkspaceAppResource(config, requestedUri, transportSessionId
367
368
  currentUri,
368
369
  compatibility,
369
370
  error: error instanceof Error ? error.message : String(error),
370
- sessionIdPrefix: sessionIdPrefix(transportSessionId),
371
+ transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
371
372
  });
372
373
  throw error;
373
374
  }
374
375
  }
375
376
  function processResult(snapshot) {
376
377
  const status = snapshot.running
377
- ? `Process running with session ID ${snapshot.sessionId}.`
378
+ ? `Process running with process ID ${snapshot.processId}.`
378
379
  : snapshot.signal
379
380
  ? `Process exited after signal ${snapshot.signal}.`
380
381
  : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`;
@@ -382,8 +383,8 @@ function processResult(snapshot) {
382
383
  }
383
384
  function completedProcessResult(snapshot) {
384
385
  const status = snapshot.signal
385
- ? `Background process ${snapshot.sessionId} exited after signal ${snapshot.signal}.`
386
- : `Background process ${snapshot.sessionId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
386
+ ? `Background process ${snapshot.processId} exited after signal ${snapshot.signal}.`
387
+ : `Background process ${snapshot.processId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
387
388
  const command = `Command: ${snapshot.command}`;
388
389
  const output = snapshot.output ? `\n${snapshot.output.replace(/\n$/, "")}` : "";
389
390
  return `${status}\n${command}${output}`;
@@ -405,10 +406,14 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
405
406
  if (!Array.isArray(content))
406
407
  return result;
407
408
  const structured = result.structuredContent;
408
- const currentSessionId = structured?.running === true && typeof structured.sessionId === "number"
409
- ? structured.sessionId
409
+ const currentProcessId = structured?.running === true
410
+ ? typeof structured.processId === "number"
411
+ ? structured.processId
412
+ : typeof structured.sessionId === "number"
413
+ ? structured.sessionId
414
+ : undefined
410
415
  : undefined;
411
- const completed = processSessions.takeCompleted(workspaceId, undefined, currentSessionId);
416
+ const completed = processSessions.takeCompleted(workspaceId, undefined, currentProcessId);
412
417
  if (completed.length === 0)
413
418
  return result;
414
419
  return {
@@ -421,7 +426,8 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
421
426
  }
422
427
  function processOutputSchema() {
423
428
  return resultOutputSchema({
424
- sessionId: z.number().optional(),
429
+ processId: z.number().int().positive().optional().describe("Canonical process handle for write_stdin."),
430
+ sessionId: z.number().int().positive().optional().describe("Deprecated alias of processId for compatibility."),
425
431
  running: z.boolean(),
426
432
  exitCode: z.number().int().optional(),
427
433
  signal: z.string().optional(),
@@ -452,6 +458,7 @@ function processToolResponse(tool, workspaceId, snapshot, summary) {
452
458
  },
453
459
  structuredContent: {
454
460
  result,
461
+ processId: snapshot.processId,
455
462
  sessionId: snapshot.sessionId,
456
463
  running: snapshot.running,
457
464
  exitCode: snapshot.exitCode,
@@ -476,7 +483,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
476
483
  if (config.toolMode === "codex") {
477
484
  registerAppTool(server, "exec_command", {
478
485
  title: "Execute command",
479
- description: `Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a sessionId for write_stdin. Use this for file inspection, tests, builds, package scripts, generators, formatters, and long-running processes. ${buildShellMutationPolicy()} Call open_workspace first and pass workspaceId.`,
486
+ description: `Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a processId for write_stdin. Use this for file inspection, tests, builds, package scripts, generators, formatters, and long-running processes. ${buildShellMutationPolicy()} Call open_workspace first and pass workspaceId.`,
480
487
  inputSchema: {
481
488
  workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
482
489
  cmd: z.string().min(1).describe("Shell command to execute."),
@@ -496,7 +503,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
496
503
  .min(0)
497
504
  .max(30_000)
498
505
  .optional()
499
- .describe("Milliseconds to wait before returning a running session. Defaults to 10000."),
506
+ .describe("Milliseconds to wait before returning a running process. Defaults to 10000."),
500
507
  maxOutputTokens: z
501
508
  .number()
502
509
  .int()
@@ -537,7 +544,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
537
544
  commandLength: cmd.length,
538
545
  exitCode: snapshot.exitCode,
539
546
  running: snapshot.running,
540
- processSessionId: snapshot.sessionId,
547
+ processId: snapshot.processId,
541
548
  success: snapshot.running || snapshot.exitCode === 0,
542
549
  durationMs: Math.round(performance.now() - startedAt),
543
550
  });
@@ -557,7 +564,8 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
557
564
  description: "Poll or write characters to a running process returned by bash or exec_command. Omit chars or pass an empty string to poll. Waiting never kills the process; pass \\u0003 to explicitly send Ctrl-C.",
558
565
  inputSchema: {
559
566
  workspaceId: z.string().describe("Workspace identifier used to start the process."),
560
- sessionId: z.number().describe("Process session identifier returned by exec_command."),
567
+ processId: z.number().int().positive().optional().describe("Canonical process identifier returned by bash or exec_command."),
568
+ sessionId: z.number().int().positive().optional().describe("Deprecated alias for processId. Retained for compatibility."),
561
569
  chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."),
562
570
  columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."),
563
571
  rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."),
@@ -579,13 +587,14 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
579
587
  outputSchema: processOutputSchema(),
580
588
  ...toolWidgetDescriptorMeta(config, "shell"),
581
589
  annotations: SHELL_TOOL_ANNOTATIONS,
582
- }, async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
590
+ }, async ({ workspaceId, processId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
583
591
  const workspace = workspaces.getWorkspace(workspaceId);
592
+ const resolvedProcessId = resolveProcessId(processId, sessionId);
584
593
  return runToolWithHooks(hooks, {
585
594
  tool: "write_stdin",
586
595
  invocation: workspaceHookInvocation(workspace),
587
596
  payload: {
588
- sessionId,
597
+ processId: resolvedProcessId,
589
598
  charactersWritten: chars?.length ?? 0,
590
599
  columns,
591
600
  rows,
@@ -594,7 +603,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
594
603
  const startedAt = performance.now();
595
604
  const snapshot = await processSessions.write({
596
605
  workspaceId,
597
- sessionId,
606
+ processId: resolvedProcessId,
598
607
  chars,
599
608
  columns,
600
609
  rows,
@@ -606,12 +615,12 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
606
615
  ...workspaceLogContext(workspace, extra.sessionId),
607
616
  exitCode: snapshot.exitCode,
608
617
  running: snapshot.running,
609
- processSessionId: snapshot.sessionId,
618
+ processId: snapshot.processId,
610
619
  success: snapshot.running || snapshot.exitCode === 0,
611
620
  durationMs: Math.round(performance.now() - startedAt),
612
621
  });
613
622
  return processToolResponse("write_stdin", workspaceId, snapshot, {
614
- sessionId,
623
+ processId: resolvedProcessId,
615
624
  charactersWritten: chars?.length ?? 0,
616
625
  running: snapshot.running,
617
626
  exitCode: snapshot.exitCode,
@@ -1747,7 +1756,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1747
1756
  commandLength: command.length,
1748
1757
  exitCode: snapshot.exitCode,
1749
1758
  running: snapshot.running,
1750
- processSessionId: snapshot.sessionId,
1759
+ processId: snapshot.processId,
1751
1760
  success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
1752
1761
  durationMs: Math.round(performance.now() - startedAt),
1753
1762
  });
@@ -1786,7 +1795,7 @@ export function createServer(config = loadConfig(), options = {}) {
1786
1795
  host: config.host,
1787
1796
  ...(allowedHosts ? { allowedHosts } : {}),
1788
1797
  });
1789
- const transports = new McpSessionRegistry();
1798
+ const transports = new McpTransportRegistry();
1790
1799
  const mcpUrl = new URL("/mcp", config.publicBaseUrl);
1791
1800
  const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
1792
1801
  const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir);
@@ -1798,17 +1807,17 @@ export function createServer(config = loadConfig(), options = {}) {
1798
1807
  const workspaceStore = createWorkspaceStore(config.stateDir);
1799
1808
  const workspaces = new WorkspaceRegistry(config, workspaceStore);
1800
1809
  const reviewCheckpoints = createReviewCheckpointManager();
1801
- const processSessions = new ProcessSessionManager();
1810
+ const processSessions = new ProcessManager();
1802
1811
  const localAgentProviders = config.subagents
1803
1812
  ? getLocalAgentProviderAvailabilitySnapshot()
1804
1813
  : [];
1805
- const logSessionCloseResults = (reason, results) => {
1814
+ const logTransportCloseResults = (reason, results) => {
1806
1815
  let closedCount = 0;
1807
1816
  for (const result of results) {
1808
1817
  if (result.error) {
1809
- logEvent(config.logging, "warn", "mcp_session_close_failed", {
1818
+ logEvent(config.logging, "warn", "mcp_transport_session_close_failed", {
1810
1819
  reason,
1811
- sessionIdPrefix: sessionIdPrefix(result.sessionId),
1820
+ transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
1812
1821
  error: result.error instanceof Error
1813
1822
  ? result.error.message
1814
1823
  : String(result.error),
@@ -1817,25 +1826,25 @@ export function createServer(config = loadConfig(), options = {}) {
1817
1826
  }
1818
1827
  closedCount += 1;
1819
1828
  if (reason === "idle_timeout") {
1820
- logEvent(config.logging, "debug", "mcp_session_closed", {
1829
+ logEvent(config.logging, "debug", "mcp_transport_session_closed", {
1821
1830
  reason,
1822
- sessionIdPrefix: sessionIdPrefix(result.sessionId),
1831
+ transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
1823
1832
  });
1824
1833
  }
1825
1834
  }
1826
1835
  if (reason === "server_shutdown" && closedCount > 0) {
1827
- logEvent(config.logging, "debug", "mcp_sessions_closed", {
1836
+ logEvent(config.logging, "debug", "mcp_transport_sessions_closed", {
1828
1837
  reason,
1829
1838
  count: closedCount,
1830
1839
  });
1831
1840
  }
1832
1841
  };
1833
- const sessionCleanupTimer = setInterval(() => {
1842
+ const transportCleanupTimer = setInterval(() => {
1834
1843
  void transports
1835
- .closeIdle(MCP_SESSION_IDLE_TIMEOUT_MS)
1836
- .then((results) => logSessionCloseResults("idle_timeout", results));
1837
- }, MCP_SESSION_CLEANUP_INTERVAL_MS);
1838
- sessionCleanupTimer.unref();
1844
+ .closeIdle(MCP_TRANSPORT_IDLE_TIMEOUT_MS)
1845
+ .then((results) => logTransportCloseResults("idle_timeout", results));
1846
+ }, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
1847
+ transportCleanupTimer.unref();
1839
1848
  if (config.logging.trustProxy) {
1840
1849
  app.set("trust proxy", true);
1841
1850
  }
@@ -1883,7 +1892,7 @@ export function createServer(config = loadConfig(), options = {}) {
1883
1892
  });
1884
1893
  app.all("/mcp", async (req, res) => {
1885
1894
  const requestId = res.locals.requestId;
1886
- const sessionId = req.header("mcp-session-id");
1895
+ const transportSessionId = req.header("mcp-session-id");
1887
1896
  const initializeRequest = req.method === "POST" && isInitializeRequest(req.body);
1888
1897
  await new Promise((resolve, reject) => {
1889
1898
  bearerAuth(req, res, (error) => {
@@ -1909,39 +1918,39 @@ export function createServer(config = loadConfig(), options = {}) {
1909
1918
  logEvent(config.logging, "debug", "mcp_request", {
1910
1919
  requestId,
1911
1920
  httpMethod: req.method,
1912
- sessionIdPresent: Boolean(sessionId),
1913
- sessionIdPrefix: sessionIdPrefix(sessionId),
1921
+ transportSessionIdPresent: Boolean(transportSessionId),
1922
+ transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
1914
1923
  isInitialize: initializeRequest,
1915
1924
  ...mcpRequestDebugFields(req.body),
1916
1925
  });
1917
1926
  try {
1918
1927
  let transport;
1919
- if (sessionId) {
1920
- transport = transports.get(sessionId);
1928
+ if (transportSessionId) {
1929
+ transport = transports.get(transportSessionId);
1921
1930
  if (!transport) {
1922
- sendJsonRpcError(res, 404, -32000, "Unknown MCP session");
1931
+ sendJsonRpcError(res, 404, -32000, "Unknown MCP transport session");
1923
1932
  return;
1924
1933
  }
1925
1934
  }
1926
1935
  else if (initializeRequest) {
1927
1936
  transport = new StreamableHTTPServerTransport({
1928
1937
  sessionIdGenerator: () => randomUUID(),
1929
- onsessioninitialized: (newSessionId) => {
1938
+ onsessioninitialized: (newTransportSessionId) => {
1930
1939
  if (transport)
1931
- transports.register(newSessionId, transport);
1932
- logEvent(config.logging, "debug", "mcp_session_created", {
1940
+ transports.register(newTransportSessionId, transport);
1941
+ logEvent(config.logging, "debug", "mcp_transport_session_created", {
1933
1942
  requestId,
1934
- sessionIdPrefix: sessionIdPrefix(newSessionId),
1943
+ transportSessionIdPrefix: transportSessionIdPrefix(newTransportSessionId),
1935
1944
  ...requestLogFields(req, config),
1936
1945
  });
1937
1946
  },
1938
1947
  });
1939
1948
  transport.onclose = () => {
1940
- const closedSessionId = transport?.sessionId;
1941
- if (closedSessionId && transports.remove(closedSessionId)) {
1942
- logEvent(config.logging, "debug", "mcp_session_closed", {
1949
+ const closedTransportSessionId = transport?.sessionId;
1950
+ if (closedTransportSessionId && transports.remove(closedTransportSessionId)) {
1951
+ logEvent(config.logging, "debug", "mcp_transport_session_closed", {
1943
1952
  reason: "transport_close",
1944
- sessionIdPrefix: sessionIdPrefix(closedSessionId),
1953
+ transportSessionIdPrefix: transportSessionIdPrefix(closedTransportSessionId),
1945
1954
  });
1946
1955
  }
1947
1956
  };
@@ -1949,7 +1958,7 @@ export function createServer(config = loadConfig(), options = {}) {
1949
1958
  await server.connect(transport);
1950
1959
  }
1951
1960
  else {
1952
- sendJsonRpcError(res, 400, -32000, "No valid MCP session");
1961
+ sendJsonRpcError(res, 400, -32000, "No valid MCP transport session");
1953
1962
  return;
1954
1963
  }
1955
1964
  await transport.handleRequest(req, res, req.body);
@@ -1971,9 +1980,9 @@ export function createServer(config = loadConfig(), options = {}) {
1971
1980
  localAgentProviders,
1972
1981
  close: () => {
1973
1982
  closePromise ??= (async () => {
1974
- clearInterval(sessionCleanupTimer);
1983
+ clearInterval(transportCleanupTimer);
1975
1984
  const results = await transports.closeAll();
1976
- logSessionCloseResults("server_shutdown", results);
1985
+ logTransportCloseResults("server_shutdown", results);
1977
1986
  processSessions.shutdown();
1978
1987
  oauthProvider.close();
1979
1988
  workspaceStore.close?.();
@@ -168,10 +168,11 @@ The exact lifecycle tools available depend on the active server configuration.
168
168
  In minimal mode, normal shell inspection commands such as `rg`, `find`, and `ls`
169
169
  can be used rather than dedicated MCP search tools. `bash` waits in the foreground
170
170
  for at most 300 seconds. If the command is still running, ForgeRelay returns a
171
- process `sessionId` without killing it. The Agent can use `write_stdin` to poll,
171
+ canonical `processId` without killing it. The Agent can use `write_stdin` to poll,
172
172
  wait again, interact, or explicitly send Ctrl-C, or continue other work; once the
173
173
  command finishes, its completion is attached to a later tool result using the
174
- same workspace ID.
174
+ same workspace ID. The former process `sessionId` remains a deprecated alias in
175
+ 0.2.x for compatibility with existing clients.
175
176
 
176
177
  `FORGERELAY_TOOL_MODE=full` adds dedicated search/directory tools.
177
178
 
@@ -132,16 +132,17 @@ receives a different ID for the same physical checkout/worktree. Pass
132
132
  `workspaceId` to `open_workspace` to explicitly resume an existing handle in the
133
133
  current conversation. `newWorkspace: true` allocates a new logical handle without
134
134
  creating another checkout or Git worktree and should be used only on explicit user
135
- request. Sessions idle for more than two days are returned in `staleWorkspaces` so
135
+ request. Logical workspaces idle for more than two days are returned in `staleWorkspaces` so
136
136
  the user can choose whether to resume or release them. `close_workspace` removes a
137
137
  logical handle without deleting checkout files; it refuses to remove the last
138
138
  handle anchoring a physical worktree.
139
139
 
140
140
  `bash` has no execution-timeout input. It waits in the foreground for at most 300
141
141
  seconds; if the process is still alive, the result contains `running: true` and a
142
- `sessionId`. `write_stdin` can poll or interact with that session for up to another
143
- 300 seconds per call. ForgeRelay does not kill a process merely because a wait
144
- window expires. Completed background processes are delivered once with a later
142
+ canonical `processId`. `write_stdin` can poll or interact with that process for up
143
+ to another 300 seconds per call. The former `sessionId` field remains a deprecated
144
+ alias during the 0.2.x compatibility window. ForgeRelay does not kill a process
145
+ merely because a wait window expires. Completed background processes are delivered once with a later
145
146
  tool result for the same logical workspace ID.
146
147
 
147
148
  ## Widgets
@@ -339,8 +340,9 @@ forgerelay agents show <id>
339
340
  short timestamps, workspace-first context, and compact operation results while
340
341
  keeping HTTP request records off by default. Project names receive stable
341
342
  per-project colors and logical `ws_...` identifiers remain visible; transient MCP
342
- transport session IDs and normal session lifecycle events are shown only at
343
- `debug` level. Shell command previews are enabled
343
+ transport session IDs and normal transport lifecycle events are shown only at
344
+ `debug` level, where they are labeled as `transport` rather than workspace/process
345
+ identity. Shell command previews are enabled
344
346
  in this mode and truncated to 120 characters; set
345
347
  `FORGERELAY_LOG_SHELL_COMMANDS=0` when command arguments may contain secrets.
346
348
 
package/docs/debugging.md CHANGED
@@ -59,10 +59,10 @@ The acceptance checks:
59
59
  3. unauthenticated `/mcp` rejection;
60
60
  4. dynamic OAuth client registration, PKCE Owner-password approval, and access-token exchange;
61
61
  5. MCP `initialize`, including package/server version consistency and the shell mutation safety contract;
62
- 6. `tools/list` for the full debug tool surface, including `close_workspace`, `write_stdin`, the non-blanket `bash` mutation policy, no kill-timeout input, the 300-second foreground-wait contract, workspace resume/stale-session schema, and MCP App tool metadata;
62
+ 6. `tools/list` for the full debug tool surface, including `close_workspace`, `write_stdin`, canonical `processId` plus the deprecated `sessionId` compatibility alias, the non-blanket `bash` mutation policy, no kill-timeout input, the 300-second foreground-wait contract, workspace resume/stale-workspace schema, and MCP App tool metadata;
63
63
  7. the full MCP App template chain: `resources/list`, `resources/templates/list`, current content-hashed `resources/read`, legacy/historical template compatibility reads, `text/html;profile=mcp-app`, CSP resource domains, and an HTTP fetch of the JavaScript asset referenced by the template;
64
- 8. a real checkout workspace with `write`, `read`, `rename`, `delete`, foreground `bash` through `ProcessSessionManager`, and a deliberate failed `edit`;
65
- 9. OS temp-directory `write` → `read` → `edit` → `rename` → `delete` over the same real MCP session, plus rejection of an arbitrary path outside the workspace/temp roots;
64
+ 8. a real checkout workspace with `write`, `read`, `rename`, `delete`, foreground `bash` through `ProcessManager`, and a deliberate failed `edit`;
65
+ 9. OS temp-directory `write` → `read` → `edit` → `rename` → `delete` over the same real MCP transport session, plus rejection of an arbitrary path outside the workspace/temp roots;
66
66
  10. a temporary Git repository with managed worktree creation, file modification, and `close_worktree`;
67
67
  11. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
68
68
  12. deterministic local subagent error path,不联系任何模型 provider;
package/docs/roadmap.md CHANGED
@@ -99,7 +99,7 @@ Hooks v1 的目标是给用户和 Agent 一个很小、自动、可组合的生
99
99
  - `workspaceId` 是唯一持久的逻辑工作身份,跨请求和 transport 重连保持连续;
100
100
  - `requestId` 只追踪单次 HTTP/JSON-RPC 请求,不持久化;
101
101
  - MCP 协议层 session 在内部和 debug 输出中明确称为 `transportSessionId`,业务状态不得依赖它;
102
- - 后台命令句柄逐步迁移为 `processId` / process handle;若公开 schema 改名需要兼容窗口,则在明确的版本边界完成;
102
+ - 后台命令句柄以 `processId` / process handle canonical 名称,旧 `sessionId` 在 0.2.x 保留 deprecated alias 兼容窗口;
103
103
  - 为 stateless MCP transport 做准备,同时保留旧协议兼容 adapter,避免把 transport 生命周期重新提升成 ForgeRelay 会话模型。
104
104
 
105
105
  ## 0.3 — LSP code intelligence v1
package/docs/security.md CHANGED
@@ -124,8 +124,9 @@ Do not describe ForgeRelay as a sandboxed coding environment.
124
124
 
125
125
  Shell execution has a 300-second foreground wait ceiling, not a 300-second
126
126
  process lifetime. When `bash` is still running after that window, ForgeRelay
127
- returns a process `sessionId` and leaves the process alive. `write_stdin` can poll,
128
- wait, interact, or explicitly interrupt it. An asynchronously completed process
127
+ returns a canonical `processId` and leaves the process alive. `write_stdin` can
128
+ poll, wait, interact, or explicitly interrupt it. The former process `sessionId`
129
+ remains a deprecated compatibility alias during 0.2.x. An asynchronously completed process
129
130
  is reported on a later tool result for the same logical workspace ID, including
130
131
  error-result paths, and is never broadcast to another workspace ID. Explicitly
131
132
  resuming the same workspace ID in another conversation intentionally transfers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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",
@@ -126,6 +126,14 @@ try {
126
126
  assert.match(bashTool?.description ?? "", /write_stdin/);
127
127
  const writeStdinTool = tools.find((tool) => tool.name === "write_stdin");
128
128
  assert.equal(writeStdinTool?.inputSchema?.properties?.yieldTimeMs?.maximum, 300000);
129
+ assert.match(
130
+ writeStdinTool?.inputSchema?.properties?.processId?.description ?? "",
131
+ /Canonical process identifier/,
132
+ );
133
+ assert.match(
134
+ writeStdinTool?.inputSchema?.properties?.sessionId?.description ?? "",
135
+ /Deprecated alias for processId/,
136
+ );
129
137
  const openWorkspaceTool = tools.find((tool) => tool.name === "open_workspace");
130
138
  assert.ok(openWorkspaceTool?.inputSchema?.properties?.workspaceId);
131
139
  assert.ok(openWorkspaceTool?.inputSchema?.properties?.newWorkspace);
@@ -217,7 +225,7 @@ try {
217
225
  });
218
226
  assert.match(shell.structuredContent.result, /debug-bash-ok/);
219
227
  assert.equal(shell.structuredContent.running, false);
220
- pass("bash", "foreground command completed through ProcessSessionManager");
228
+ pass("bash", "foreground command completed through ProcessManager");
221
229
 
222
230
  const failedEdit = callTool(oauth.accessToken, sessionId, 7, "edit", {
223
231
  workspaceId,