@akira-tl/forgerelay 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +15 -0
- package/capabilities/artifacts-review/GUIDE.md +42 -0
- package/capabilities/host-integration/GUIDE.md +68 -0
- package/capabilities/lifecycle-hooks/GUIDE.md +39 -0
- package/capabilities/managed-worktrees/GUIDE.md +43 -0
- package/capabilities/shell-processes/GUIDE.md +51 -0
- package/capabilities/subagents/GUIDE.md +69 -0
- package/dist/advertised-files.js +23 -0
- package/dist/artifact-tools.js +2 -3
- package/dist/capabilities.js +99 -0
- package/dist/cli.js +1 -3
- package/dist/config.js +11 -3
- package/dist/logger.js +25 -23
- package/dist/mcp/server-instructions.js +16 -31
- package/dist/mcp-sessions.js +24 -22
- package/dist/process-sessions.js +135 -108
- package/dist/server.js +108 -70
- package/dist/skills.js +11 -30
- package/dist/workspaces.js +16 -0
- package/docs/chatgpt-coding-workflow.md +58 -15
- package/docs/configuration.md +53 -8
- package/docs/debugging.md +3 -3
- package/docs/roadmap.md +30 -4
- package/docs/security.md +27 -9
- package/package.json +4 -3
- package/scripts/debug/accept.mjs +47 -4
- package/scripts/ensure-cli-executable.mjs +12 -0
package/dist/logger.js
CHANGED
|
@@ -47,23 +47,17 @@ export function logEvent(config, level, event, fields = {}) {
|
|
|
47
47
|
console.log(line);
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
-
export function requestIp(req
|
|
51
|
-
if (trustProxy) {
|
|
52
|
-
const cfConnectingIp = firstHeaderValue(req.header("cf-connecting-ip"));
|
|
53
|
-
if (cfConnectingIp)
|
|
54
|
-
return cfConnectingIp;
|
|
55
|
-
const forwardedFor = firstHeaderValue(req.header("x-forwarded-for"));
|
|
56
|
-
if (forwardedFor)
|
|
57
|
-
return forwardedFor;
|
|
58
|
-
}
|
|
50
|
+
export function requestIp(req) {
|
|
59
51
|
return req.ip ?? req.socket.remoteAddress;
|
|
60
52
|
}
|
|
61
53
|
export function requestPath(req) {
|
|
62
54
|
return req.path || req.url.split("?")[0] || req.url;
|
|
63
55
|
}
|
|
64
|
-
export function
|
|
65
|
-
return
|
|
56
|
+
export function transportSessionIdPrefix(transportSessionId) {
|
|
57
|
+
return transportSessionId ? transportSessionId.slice(0, 8) : undefined;
|
|
66
58
|
}
|
|
59
|
+
/** @deprecated Use transportSessionIdPrefix. */
|
|
60
|
+
export const sessionIdPrefix = transportSessionIdPrefix;
|
|
67
61
|
export function workspaceLogLabel(root, workspaceId) {
|
|
68
62
|
const shortWorkspaceId = workspaceId.startsWith("ws_")
|
|
69
63
|
? `ws_${workspaceId.slice(3, 11)}`
|
|
@@ -78,21 +72,20 @@ export function formatPrettyLogEntry(entry, options = {}) {
|
|
|
78
72
|
const level = logLevel(entry.level);
|
|
79
73
|
const time = formatTimestamp(entry.ts);
|
|
80
74
|
const source = stringField(entry.workspace) ?? stringField(entry.workspaceId) ?? "forgerelay";
|
|
81
|
-
const
|
|
82
|
-
? stringField(entry.
|
|
75
|
+
const transportSession = level === "debug"
|
|
76
|
+
? stringField(entry.transportSessionIdPrefix)
|
|
77
|
+
?? stringField(entry.session)
|
|
78
|
+
?? stringField(entry.sessionIdPrefix)
|
|
83
79
|
: undefined;
|
|
84
80
|
const prefix = [
|
|
85
81
|
style("gray", time, options),
|
|
86
82
|
`[${style(LEVEL_STYLE[level], level.toUpperCase(), options)}]`,
|
|
87
83
|
formatPrettySource(source, options),
|
|
88
|
-
|
|
84
|
+
transportSession ? style("gray", `transport:${transportSession}`, options) : undefined,
|
|
89
85
|
style("gray", "|", options),
|
|
90
86
|
].filter((value) => Boolean(value)).join(" ");
|
|
91
87
|
return `${prefix} ${formatPrettyMessage(entry, options)}`;
|
|
92
88
|
}
|
|
93
|
-
function firstHeaderValue(value) {
|
|
94
|
-
return value?.split(",")[0]?.trim() || undefined;
|
|
95
|
-
}
|
|
96
89
|
function formatPrettyMessage(entry, options) {
|
|
97
90
|
switch (String(entry.event)) {
|
|
98
91
|
case "tool_call":
|
|
@@ -108,14 +101,18 @@ function formatPrettyMessage(entry, options) {
|
|
|
108
101
|
return formatAppTemplateMessage(entry, options, false);
|
|
109
102
|
case "mcp_app_template_read_failed":
|
|
110
103
|
return formatAppTemplateMessage(entry, options, true);
|
|
104
|
+
case "mcp_transport_session_created":
|
|
111
105
|
case "mcp_session_created":
|
|
112
|
-
return `session ${
|
|
106
|
+
return `transport session ${transportSessionPrefix(entry) ?? "unknown"} created`;
|
|
107
|
+
case "mcp_transport_session_closed":
|
|
113
108
|
case "mcp_session_closed":
|
|
114
|
-
return `session ${
|
|
109
|
+
return `transport session ${transportSessionPrefix(entry) ?? "unknown"} closed`;
|
|
110
|
+
case "mcp_transport_sessions_closed":
|
|
115
111
|
case "mcp_sessions_closed":
|
|
116
|
-
return `${numberField(entry.count) ?? 0} sessions closed`;
|
|
112
|
+
return `${numberField(entry.count) ?? 0} transport sessions closed`;
|
|
113
|
+
case "mcp_transport_session_close_failed":
|
|
117
114
|
case "mcp_session_close_failed":
|
|
118
|
-
return `session ${
|
|
115
|
+
return `transport session ${transportSessionPrefix(entry) ?? "unknown"} close -> ${style("red", "error", options)}`;
|
|
119
116
|
case "auth_denied":
|
|
120
117
|
return `auth denied${entry.reason ? `: ${String(entry.reason)}` : ""}`;
|
|
121
118
|
case "mcp_request_error":
|
|
@@ -139,8 +136,8 @@ function toolTarget(entry, tool) {
|
|
|
139
136
|
}
|
|
140
137
|
function toolResult(entry, tool, options) {
|
|
141
138
|
if (entry.running === true) {
|
|
142
|
-
const
|
|
143
|
-
return style("yellow",
|
|
139
|
+
const processId = entry.processId ?? entry.processSessionId;
|
|
140
|
+
return style("yellow", processId === undefined ? "running" : `running process:${String(processId)}`, options);
|
|
144
141
|
}
|
|
145
142
|
const exitCode = numberField(entry.exitCode) ?? exitCodeFromError(entry.error);
|
|
146
143
|
if (isShellTool(tool)) {
|
|
@@ -235,6 +232,11 @@ function logLevel(value) {
|
|
|
235
232
|
function stringField(value) {
|
|
236
233
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
237
234
|
}
|
|
235
|
+
function transportSessionPrefix(entry) {
|
|
236
|
+
return stringField(entry.transportSessionIdPrefix)
|
|
237
|
+
?? stringField(entry.sessionIdPrefix)
|
|
238
|
+
?? stringField(entry.session);
|
|
239
|
+
}
|
|
238
240
|
function numberField(value) {
|
|
239
241
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
240
242
|
}
|
|
@@ -14,57 +14,42 @@ export const toolNames = {
|
|
|
14
14
|
writeStdin: "write_stdin",
|
|
15
15
|
};
|
|
16
16
|
export function buildShellMutationPolicy() {
|
|
17
|
-
return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
|
|
17
|
+
return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. They may also perform external device or hardware mutations when the user's current request explicitly asks for the actual device-changing operation, including firmware flashing or equivalent persistent device updates; do not infer such authorization from a check, audit, probe, backup, verification, dry-run, or build-only request. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
|
|
18
18
|
}
|
|
19
|
-
export function buildServerInstructions(config
|
|
20
|
-
return joinInstructions(capabilityContractInstructions(config
|
|
19
|
+
export function buildServerInstructions(config) {
|
|
20
|
+
return joinInstructions(capabilityContractInstructions(config), selectedWorkflowInstructions(config), config.appendInstructions);
|
|
21
21
|
}
|
|
22
22
|
export function buildToolDescriptions(config) {
|
|
23
23
|
const skillCapability = config.skillsEnabled
|
|
24
|
-
? " Advertised skill paths may be outside the workspace
|
|
24
|
+
? " Advertised skill paths may also be outside the workspace."
|
|
25
25
|
: "";
|
|
26
26
|
const shellSurface = config.toolMode === "minimal"
|
|
27
27
|
? ` In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled, so shell commands may be used for equivalent search and directory inspection.`
|
|
28
28
|
: "";
|
|
29
|
-
const shellMutationPolicy = buildShellMutationPolicy();
|
|
30
29
|
return {
|
|
31
|
-
read: `Read a file inside an open workspace or the OS temp directory. Instruction files returned by ${toolNames.openWorkspace}
|
|
30
|
+
read: `Read a file inside an open workspace or the OS temp directory. Instruction files and advertised capability guides returned by ${toolNames.openWorkspace} are also readable when applicable.${skillCapability} Only advertised entry files and files under already-loaded advertised directories are readable outside the normal roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
32
31
|
write: `Create or completely overwrite a file inside an open workspace or the OS temp directory. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
33
32
|
edit: `Edit one file inside an open workspace or the OS temp directory by replacing exact text blocks. Each oldText must match a unique, non-overlapping region of the original file. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
34
33
|
rename: `Rename or move one file or directory inside an open workspace or the OS temp directory without overwriting an existing destination. Source and destination must both remain inside the permitted file roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
35
34
|
delete: `Delete one file or directory inside an open workspace or the OS temp directory. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
36
35
|
applyPatch: `Apply one Codex-style patch inside an open workspace or the OS temp directory. Supports adding, overwriting, updating, deleting, and moving files. Workspace paths must remain relative; absolute paths are accepted only inside the OS temp directory. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
37
|
-
shell: `Run a shell command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. ForgeRelay waits up to 300 seconds
|
|
36
|
+
shell: `Run a shell command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. ForgeRelay waits up to 300 seconds, then returns a processId for a still-running command; use ${toolNames.writeStdin} to poll, interact, wait, or send Ctrl-C. Completed background commands may be reported later for the same workspaceId. Call ${toolNames.openWorkspace} first and pass workspaceId. Expose this capability only behind strong authentication.`,
|
|
38
37
|
shellCommand: "Shell command to run with the local user's authority.",
|
|
39
38
|
};
|
|
40
39
|
}
|
|
41
|
-
function capabilityContractInstructions(config
|
|
42
|
-
const
|
|
43
|
-
?
|
|
44
|
-
: `
|
|
45
|
-
const
|
|
40
|
+
function capabilityContractInstructions(config) {
|
|
41
|
+
const staleWorkspacePolicy = config.toolMode === "codex"
|
|
42
|
+
? ""
|
|
43
|
+
: ` If ${toolNames.openWorkspace} reports logical workspaces idle for more than two days, let the user choose whether to resume or close them with ${toolNames.closeWorkspace}; never close them automatically.`;
|
|
44
|
+
const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse the workspaceId returned by ${toolNames.openWorkspace} for this conversation; resume another logical workspaceId only when the user wants that workspace, and request a new logical workspace only when explicitly asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} releases a logical workspace; ${toolNames.closeWorktree} finalizes a managed worktree. Read the managed-worktrees capability guide for advanced worktree lifecycle and failure semantics.`;
|
|
45
|
+
const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
|
|
46
|
+
const capabilityGuides = `When ${toolNames.openWorkspace} returns capability guides, use ${toolNames.read} to load only a task-relevant guide; do not preload all guides.`;
|
|
46
47
|
const skills = config.skillsEnabled
|
|
47
|
-
? `When
|
|
48
|
+
? `When a task matches an available skill from ${toolNames.openWorkspace}, read its advertised path before proceeding. Outside normal file roots, ${toolNames.read} permits only advertised entry files and files under already-loaded advertised directories.`
|
|
48
49
|
: "";
|
|
49
|
-
const toolSurface = toolSurfaceInstructions(config);
|
|
50
50
|
const shellMutationPolicy = buildShellMutationPolicy();
|
|
51
51
|
const hooks = "When a ForgeRelay tool result reports Hook results, tell the user which meaningful hooks ran and whether they passed or blocked the operation. Do not claim the requested operation succeeded when a blocking hook prevented it.";
|
|
52
|
-
|
|
53
|
-
? "When the user supplies or generates a file that is not present on the ForgeRelay host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs."
|
|
54
|
-
: "";
|
|
55
|
-
const showChanges = config.widgets === "changes"
|
|
56
|
-
? "If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs."
|
|
57
|
-
: "";
|
|
58
|
-
return joinInstructions(workspaceLifecycle, agents, skills, toolSurface, shellMutationPolicy, hooks, artifact, showChanges);
|
|
59
|
-
}
|
|
60
|
-
function toolSurfaceInstructions(config) {
|
|
61
|
-
if (config.toolMode === "codex") {
|
|
62
|
-
return `In codex tool mode, workspace file and command operations use ${toolNames.read}, ${toolNames.rename}, ${toolNames.delete}, apply_patch, exec_command, and ${toolNames.writeStdin}.`;
|
|
63
|
-
}
|
|
64
|
-
if (config.toolMode === "full") {
|
|
65
|
-
return `In full tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are available alongside the core workspace tools. ${toolNames.writeStdin} is available for running bash sessions.`;
|
|
66
|
-
}
|
|
67
|
-
return `In minimal tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are disabled; the core workspace tools remain available, including ${toolNames.writeStdin} for running bash sessions.`;
|
|
52
|
+
return joinInstructions(workspaceLifecycle, agents, capabilityGuides, skills, shellMutationPolicy, hooks);
|
|
68
53
|
}
|
|
69
54
|
function selectedWorkflowInstructions(config) {
|
|
70
55
|
if (config.workflowInstructions === false)
|
|
@@ -80,7 +65,7 @@ function defaultWorkflowInstructions(config) {
|
|
|
80
65
|
const inspection = config.toolMode === "full"
|
|
81
66
|
? `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection.`
|
|
82
67
|
: `Use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection.`;
|
|
83
|
-
return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, git inspection, package scripts, generators, formatters, and commands that are better executed by the shell. If ${toolNames.shell} returns a running
|
|
68
|
+
return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, git inspection, package scripts, generators, formatters, and commands that are better executed by the shell. If ${toolNames.shell} returns a running process with a processId, use ${toolNames.writeStdin} only when you need to poll, wait, interact, or interrupt it; otherwise you may continue other work and consume its completion notice from a later tool result.`);
|
|
84
69
|
}
|
|
85
70
|
function joinInstructions(...parts) {
|
|
86
71
|
return parts
|
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 };
|