@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 +8 -0
- package/dist/artifact-tools.js +2 -3
- package/dist/logger.js +24 -11
- package/dist/mcp/server-instructions.js +4 -4
- package/dist/mcp-sessions.js +24 -22
- package/dist/process-sessions.js +135 -108
- package/dist/server.js +66 -57
- package/docs/chatgpt-coding-workflow.md +3 -2
- package/docs/configuration.md +8 -6
- package/docs/debugging.md +3 -3
- package/docs/roadmap.md +1 -1
- package/docs/security.md +3 -2
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +9 -1
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
|
package/dist/artifact-tools.js
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,
|
|
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
|
|
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
|
|
65
|
-
return
|
|
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
|
|
82
|
-
? stringField(entry.
|
|
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
|
-
|
|
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 ${
|
|
117
|
+
return `transport session ${transportSessionPrefix(entry) ?? "unknown"} created`;
|
|
118
|
+
case "mcp_transport_session_closed":
|
|
113
119
|
case "mcp_session_closed":
|
|
114
|
-
return `session ${
|
|
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 ${
|
|
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
|
|
143
|
-
return style("yellow",
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/mcp-sessions.js
CHANGED
|
@@ -1,56 +1,58 @@
|
|
|
1
|
-
export class
|
|
2
|
-
|
|
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.
|
|
8
|
+
return this.transports.size;
|
|
9
9
|
}
|
|
10
|
-
register(
|
|
11
|
-
this.
|
|
10
|
+
register(transportSessionId, transport) {
|
|
11
|
+
this.transports.set(transportSessionId, {
|
|
12
12
|
transport,
|
|
13
13
|
lastActivityAt: this.now(),
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
|
-
get(
|
|
17
|
-
const entry = this.
|
|
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(
|
|
24
|
-
return this.
|
|
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
|
|
29
|
-
for (const [
|
|
28
|
+
const idleTransports = [];
|
|
29
|
+
for (const [transportSessionId, entry] of this.transports) {
|
|
30
30
|
if (entry.lastActivityAt > cutoff)
|
|
31
31
|
continue;
|
|
32
|
-
this.
|
|
33
|
-
|
|
32
|
+
this.transports.delete(transportSessionId);
|
|
33
|
+
idleTransports.push({ transportSessionId, transport: entry.transport });
|
|
34
34
|
}
|
|
35
|
-
return
|
|
35
|
+
return closeTransports(idleTransports);
|
|
36
36
|
}
|
|
37
37
|
async closeAll() {
|
|
38
|
-
const
|
|
39
|
-
|
|
38
|
+
const transports = Array.from(this.transports, ([transportSessionId, entry]) => ({
|
|
39
|
+
transportSessionId,
|
|
40
40
|
transport: entry.transport,
|
|
41
41
|
}));
|
|
42
|
-
this.
|
|
43
|
-
return
|
|
42
|
+
this.transports.clear();
|
|
43
|
+
return closeTransports(transports);
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
async function
|
|
47
|
-
return Promise.all(
|
|
46
|
+
async function closeTransports(transports) {
|
|
47
|
+
return Promise.all(transports.map(async ({ transportSessionId, transport }) => {
|
|
48
48
|
try {
|
|
49
49
|
await transport.close();
|
|
50
|
-
return {
|
|
50
|
+
return { transportSessionId };
|
|
51
51
|
}
|
|
52
52
|
catch (error) {
|
|
53
|
-
return {
|
|
53
|
+
return { transportSessionId, error };
|
|
54
54
|
}
|
|
55
55
|
}));
|
|
56
56
|
}
|
|
57
|
+
/** @deprecated Use McpTransportRegistry. */
|
|
58
|
+
export { McpTransportRegistry as McpSessionRegistry };
|
package/dist/process-sessions.js
CHANGED
|
@@ -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
|
|
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
|
|
133
|
-
|
|
145
|
+
export class ProcessManager {
|
|
146
|
+
processes = new Map();
|
|
134
147
|
completedByWorkspace = new Map();
|
|
135
148
|
maxBufferCharacters;
|
|
136
|
-
|
|
149
|
+
completedProcessTtlMs;
|
|
137
150
|
maxStartYieldMs;
|
|
138
|
-
|
|
151
|
+
monotonicNow;
|
|
152
|
+
nextProcessId = 1;
|
|
139
153
|
constructor(options = {}) {
|
|
140
154
|
this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
|
|
141
|
-
this.
|
|
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
|
|
146
|
-
this.
|
|
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(
|
|
166
|
+
await this.startPty(processEntry, input);
|
|
150
167
|
else
|
|
151
|
-
this.startPipe(
|
|
168
|
+
this.startPipe(processEntry, input);
|
|
152
169
|
}
|
|
153
170
|
catch (error) {
|
|
154
|
-
this.
|
|
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(
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
const snapshot = this.consume(
|
|
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.
|
|
180
|
+
this.removeProcess(processEntry.id);
|
|
164
181
|
return snapshot;
|
|
165
182
|
}
|
|
166
183
|
async write(input) {
|
|
167
|
-
const
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
if (!
|
|
174
|
-
throw new Error(`Process
|
|
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
|
-
|
|
194
|
+
processEntry.process.resize(processEntry.columns, processEntry.rows);
|
|
177
195
|
}
|
|
178
|
-
const interruptRequested = chars.includes("\u0003") &&
|
|
196
|
+
const interruptRequested = chars.includes("\u0003") && processEntry.running;
|
|
179
197
|
if (interruptRequested) {
|
|
180
|
-
|
|
198
|
+
processEntry.process?.kill("SIGINT");
|
|
181
199
|
}
|
|
182
200
|
const writableChars = chars.replaceAll("\u0003", "");
|
|
183
|
-
if (writableChars &&
|
|
184
|
-
|
|
185
|
-
if ((interactionRequested || !
|
|
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(
|
|
207
|
+
await this.waitForExit(processEntry, yieldTimeMs);
|
|
190
208
|
}
|
|
191
|
-
const snapshot = this.consume(
|
|
192
|
-
if (!
|
|
193
|
-
this.
|
|
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.
|
|
215
|
+
return new Set([...this.processes.values()].map((processEntry) => processEntry.workspaceId));
|
|
198
216
|
}
|
|
199
|
-
takeCompleted(workspaceId, maxOutputTokens,
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
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
|
|
205
|
-
if (
|
|
222
|
+
for (const processId of processIds) {
|
|
223
|
+
if (processId === excludeProcessId)
|
|
206
224
|
continue;
|
|
207
|
-
const
|
|
208
|
-
if (!
|
|
225
|
+
const processEntry = this.processes.get(processId);
|
|
226
|
+
if (!processEntry || processEntry.running)
|
|
209
227
|
continue;
|
|
210
|
-
const snapshot = this.consume(
|
|
211
|
-
completed.push({
|
|
212
|
-
|
|
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,
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
219
|
-
|
|
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
|
|
223
|
-
if (
|
|
224
|
-
clearTimeout(
|
|
225
|
-
if (
|
|
226
|
-
|
|
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.
|
|
251
|
+
this.processes.clear();
|
|
229
252
|
this.completedByWorkspace.clear();
|
|
230
253
|
}
|
|
231
|
-
async waitForExit(
|
|
254
|
+
async waitForExit(processEntry, yieldTimeMs) {
|
|
232
255
|
let timer;
|
|
233
256
|
try {
|
|
234
257
|
await Promise.race([
|
|
235
|
-
|
|
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
|
-
|
|
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.
|
|
275
|
+
id: this.nextProcessId++,
|
|
253
276
|
workspaceId: input.workspaceId,
|
|
254
277
|
command: input.command,
|
|
255
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
286
|
-
child.stderr.on("data", (data) => this.append(
|
|
287
|
-
child.on("error", (error) => this.append(
|
|
288
|
-
child.on("close", (code, signal) => this.finish(
|
|
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(
|
|
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:
|
|
310
|
-
rows:
|
|
332
|
+
cols: processEntry.columns,
|
|
333
|
+
rows: processEntry.rows,
|
|
311
334
|
});
|
|
312
335
|
}
|
|
313
336
|
catch (error) {
|
|
314
337
|
throw error;
|
|
315
338
|
}
|
|
316
|
-
|
|
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(
|
|
344
|
+
pty.onData((data) => this.append(processEntry, data));
|
|
322
345
|
pty.onExit(({ exitCode, signal }) => {
|
|
323
|
-
this.finish(
|
|
346
|
+
this.finish(processEntry, exitCode, signal === 0 ? undefined : String(signal));
|
|
324
347
|
});
|
|
325
348
|
}
|
|
326
|
-
finish(
|
|
327
|
-
if (!
|
|
349
|
+
finish(processEntry, exitCode, signal) {
|
|
350
|
+
if (!processEntry.running)
|
|
328
351
|
return;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
const completed = this.completedByWorkspace.get(
|
|
335
|
-
if (!completed.includes(
|
|
336
|
-
completed.push(
|
|
337
|
-
this.completedByWorkspace.set(
|
|
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
|
-
|
|
341
|
-
|
|
363
|
+
processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
|
|
364
|
+
processEntry.cleanupTimer.unref();
|
|
342
365
|
}
|
|
343
|
-
append(
|
|
344
|
-
|
|
366
|
+
append(processEntry, output) {
|
|
367
|
+
processEntry.buffer.append(output);
|
|
345
368
|
}
|
|
346
|
-
consume(
|
|
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 =
|
|
372
|
+
const buffered = processEntry.buffer.drain(maxCharacters);
|
|
373
|
+
const processId = processEntry.running ? processEntry.id : undefined;
|
|
350
374
|
return {
|
|
351
|
-
|
|
375
|
+
processId,
|
|
376
|
+
sessionId: processId,
|
|
352
377
|
output: buffered.output,
|
|
353
378
|
outputTruncated: buffered.truncated,
|
|
354
|
-
running:
|
|
355
|
-
exitCode:
|
|
356
|
-
signal:
|
|
357
|
-
wallTimeMs:
|
|
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
|
-
|
|
361
|
-
const
|
|
362
|
-
if (!
|
|
363
|
-
throw new Error(`Unknown process
|
|
364
|
-
if (
|
|
365
|
-
throw new Error(`Process
|
|
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
|
|
392
|
+
return processEntry;
|
|
368
393
|
}
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
if (
|
|
372
|
-
clearTimeout(
|
|
373
|
-
this.
|
|
374
|
-
if (!
|
|
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(
|
|
401
|
+
const completed = this.completedByWorkspace.get(processEntry.workspaceId);
|
|
377
402
|
if (!completed)
|
|
378
403
|
return;
|
|
379
|
-
const remaining = completed.filter((id) => id !==
|
|
404
|
+
const remaining = completed.filter((id) => id !== processId);
|
|
380
405
|
if (remaining.length > 0)
|
|
381
|
-
this.completedByWorkspace.set(
|
|
406
|
+
this.completedByWorkspace.set(processEntry.workspaceId, remaining);
|
|
382
407
|
else
|
|
383
|
-
this.completedByWorkspace.delete(
|
|
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,
|
|
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 {
|
|
27
|
-
import {
|
|
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
|
|
38
|
-
// session retention so abandoned
|
|
39
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
386
|
-
: `Background process ${snapshot.
|
|
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
|
|
409
|
-
? structured.
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1810
|
+
const processSessions = new ProcessManager();
|
|
1802
1811
|
const localAgentProviders = config.subagents
|
|
1803
1812
|
? getLocalAgentProviderAvailabilitySnapshot()
|
|
1804
1813
|
: [];
|
|
1805
|
-
const
|
|
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", "
|
|
1818
|
+
logEvent(config.logging, "warn", "mcp_transport_session_close_failed", {
|
|
1810
1819
|
reason,
|
|
1811
|
-
|
|
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", "
|
|
1829
|
+
logEvent(config.logging, "debug", "mcp_transport_session_closed", {
|
|
1821
1830
|
reason,
|
|
1822
|
-
|
|
1831
|
+
transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
|
|
1823
1832
|
});
|
|
1824
1833
|
}
|
|
1825
1834
|
}
|
|
1826
1835
|
if (reason === "server_shutdown" && closedCount > 0) {
|
|
1827
|
-
logEvent(config.logging, "debug", "
|
|
1836
|
+
logEvent(config.logging, "debug", "mcp_transport_sessions_closed", {
|
|
1828
1837
|
reason,
|
|
1829
1838
|
count: closedCount,
|
|
1830
1839
|
});
|
|
1831
1840
|
}
|
|
1832
1841
|
};
|
|
1833
|
-
const
|
|
1842
|
+
const transportCleanupTimer = setInterval(() => {
|
|
1834
1843
|
void transports
|
|
1835
|
-
.closeIdle(
|
|
1836
|
-
.then((results) =>
|
|
1837
|
-
},
|
|
1838
|
-
|
|
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
|
|
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
|
-
|
|
1913
|
-
|
|
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 (
|
|
1920
|
-
transport = transports.get(
|
|
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: (
|
|
1938
|
+
onsessioninitialized: (newTransportSessionId) => {
|
|
1930
1939
|
if (transport)
|
|
1931
|
-
transports.register(
|
|
1932
|
-
logEvent(config.logging, "debug", "
|
|
1940
|
+
transports.register(newTransportSessionId, transport);
|
|
1941
|
+
logEvent(config.logging, "debug", "mcp_transport_session_created", {
|
|
1933
1942
|
requestId,
|
|
1934
|
-
|
|
1943
|
+
transportSessionIdPrefix: transportSessionIdPrefix(newTransportSessionId),
|
|
1935
1944
|
...requestLogFields(req, config),
|
|
1936
1945
|
});
|
|
1937
1946
|
},
|
|
1938
1947
|
});
|
|
1939
1948
|
transport.onclose = () => {
|
|
1940
|
-
const
|
|
1941
|
-
if (
|
|
1942
|
-
logEvent(config.logging, "debug", "
|
|
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
|
-
|
|
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(
|
|
1983
|
+
clearInterval(transportCleanupTimer);
|
|
1975
1984
|
const results = await transports.closeAll();
|
|
1976
|
-
|
|
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
|
-
|
|
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
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
|
-
`
|
|
143
|
-
300 seconds per call.
|
|
144
|
-
|
|
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
|
|
343
|
-
`debug` level
|
|
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-
|
|
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 `
|
|
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
|
-
-
|
|
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
|
|
128
|
-
wait, interact, or explicitly interrupt it.
|
|
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
package/scripts/debug/accept.mjs
CHANGED
|
@@ -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
|
|
228
|
+
pass("bash", "foreground command completed through ProcessManager");
|
|
221
229
|
|
|
222
230
|
const failedEdit = callTool(oauth.accessToken, sessionId, 7, "edit", {
|
|
223
231
|
workspaceId,
|