@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/server.js
CHANGED
|
@@ -14,17 +14,18 @@ import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@mode
|
|
|
14
14
|
import express from "express";
|
|
15
15
|
import * as z from "zod/v4";
|
|
16
16
|
import { applyPatch } from "./apply-patch.js";
|
|
17
|
+
import { buildCapabilityFingerprint } from "./capabilities.js";
|
|
17
18
|
import { deletePath, renamePath } from "./file-mutations.js";
|
|
18
19
|
import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js";
|
|
19
20
|
import { loadConfig } from "./config.js";
|
|
20
21
|
import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
|
|
21
22
|
import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
|
|
22
23
|
import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
|
|
23
|
-
import { logEvent, requestIp, requestPath, commandPreview,
|
|
24
|
+
import { logEvent, requestIp, requestPath, commandPreview, transportSessionIdPrefix, workspaceLogLabel, } from "./logger.js";
|
|
24
25
|
import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, writeFileTool, } from "./pi-tools.js";
|
|
25
26
|
import { SingleUserOAuthProvider } from "./oauth-provider.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
27
|
+
import { McpTransportRegistry, } from "./mcp-sessions.js";
|
|
28
|
+
import { ProcessManager, resolveProcessId, } from "./process-sessions.js";
|
|
28
29
|
import { createReviewCheckpointManager } from "./review-checkpoints.js";
|
|
29
30
|
import { openAiConversationScopeId } from "./request-meta.js";
|
|
30
31
|
import { readWorkspaceAppManifestEntry, resolveWorkspaceAppIdentity, WORKSPACE_APP_LEGACY_URI, WORKSPACE_APP_URI_TEMPLATE, } from "./mcp-app-template.js";
|
|
@@ -34,11 +35,12 @@ import { createWorkspaceStore } from "./workspace-store.js";
|
|
|
34
35
|
import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
|
|
35
36
|
import { summarizeLocalAgentProfile } from "./local-agent-profiles.js";
|
|
36
37
|
import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js";
|
|
37
|
-
// MCP clients can reconnect without closing the previous
|
|
38
|
-
// session retention so abandoned
|
|
39
|
-
|
|
38
|
+
// Legacy MCP Streamable HTTP clients can reconnect without closing the previous
|
|
39
|
+
// transport. Bound stale transport-session retention so abandoned transports do
|
|
40
|
+
// not accumulate for the life of the process.
|
|
41
|
+
const MCP_TRANSPORT_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
|
|
40
42
|
const FORGERELAY_VERSION = readForgeRelayVersion();
|
|
41
|
-
const
|
|
43
|
+
const MCP_TRANSPORT_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
|
|
42
44
|
const WRITE_TOOL_ANNOTATIONS = {
|
|
43
45
|
readOnlyHint: false,
|
|
44
46
|
destructiveHint: true,
|
|
@@ -81,7 +83,7 @@ function toolWidgetDescriptorMeta(config, kind) {
|
|
|
81
83
|
},
|
|
82
84
|
};
|
|
83
85
|
}
|
|
84
|
-
function workspaceLogContext(workspace,
|
|
86
|
+
function workspaceLogContext(workspace, _transportSessionId) {
|
|
85
87
|
return {
|
|
86
88
|
workspaceId: workspace.id,
|
|
87
89
|
workspace: workspaceLogLabel(workspace.root, workspace.id),
|
|
@@ -111,6 +113,17 @@ const workspaceSkillOutputSchema = z.object({
|
|
|
111
113
|
description: z.string(),
|
|
112
114
|
path: z.string(),
|
|
113
115
|
});
|
|
116
|
+
const capabilityFingerprintOutputSchema = z.object({
|
|
117
|
+
version: z.string(),
|
|
118
|
+
toolMode: z.enum(["minimal", "full", "codex"]),
|
|
119
|
+
capabilities: z.array(z.string()),
|
|
120
|
+
});
|
|
121
|
+
const capabilityGuideOutputSchema = z.object({
|
|
122
|
+
name: z.string(),
|
|
123
|
+
description: z.string(),
|
|
124
|
+
whenToRead: z.string(),
|
|
125
|
+
path: z.string(),
|
|
126
|
+
});
|
|
114
127
|
const workspaceAgentsFileOutputSchema = z.object({
|
|
115
128
|
path: z.string(),
|
|
116
129
|
content: z.string(),
|
|
@@ -153,7 +166,7 @@ function sendJsonRpcError(res, status, code, message) {
|
|
|
153
166
|
}
|
|
154
167
|
function requestLogFields(req, config) {
|
|
155
168
|
return {
|
|
156
|
-
ip: requestIp(req
|
|
169
|
+
ip: requestIp(req),
|
|
157
170
|
host: req.header("host"),
|
|
158
171
|
userAgent: req.header("user-agent"),
|
|
159
172
|
origin: req.header("origin"),
|
|
@@ -357,7 +370,7 @@ async function readWorkspaceAppResource(config, requestedUri, transportSessionId
|
|
|
357
370
|
requestedUri,
|
|
358
371
|
currentUri,
|
|
359
372
|
compatibility,
|
|
360
|
-
|
|
373
|
+
transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
|
|
361
374
|
});
|
|
362
375
|
return result;
|
|
363
376
|
}
|
|
@@ -367,14 +380,14 @@ async function readWorkspaceAppResource(config, requestedUri, transportSessionId
|
|
|
367
380
|
currentUri,
|
|
368
381
|
compatibility,
|
|
369
382
|
error: error instanceof Error ? error.message : String(error),
|
|
370
|
-
|
|
383
|
+
transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
|
|
371
384
|
});
|
|
372
385
|
throw error;
|
|
373
386
|
}
|
|
374
387
|
}
|
|
375
388
|
function processResult(snapshot) {
|
|
376
389
|
const status = snapshot.running
|
|
377
|
-
? `Process running with
|
|
390
|
+
? `Process running with process ID ${snapshot.processId}.`
|
|
378
391
|
: snapshot.signal
|
|
379
392
|
? `Process exited after signal ${snapshot.signal}.`
|
|
380
393
|
: `Process exited with code ${snapshot.exitCode ?? "unknown"}.`;
|
|
@@ -382,8 +395,8 @@ function processResult(snapshot) {
|
|
|
382
395
|
}
|
|
383
396
|
function completedProcessResult(snapshot) {
|
|
384
397
|
const status = snapshot.signal
|
|
385
|
-
? `Background process ${snapshot.
|
|
386
|
-
: `Background process ${snapshot.
|
|
398
|
+
? `Background process ${snapshot.processId} exited after signal ${snapshot.signal}.`
|
|
399
|
+
: `Background process ${snapshot.processId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
|
|
387
400
|
const command = `Command: ${snapshot.command}`;
|
|
388
401
|
const output = snapshot.output ? `\n${snapshot.output.replace(/\n$/, "")}` : "";
|
|
389
402
|
return `${status}\n${command}${output}`;
|
|
@@ -405,10 +418,14 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
|
|
|
405
418
|
if (!Array.isArray(content))
|
|
406
419
|
return result;
|
|
407
420
|
const structured = result.structuredContent;
|
|
408
|
-
const
|
|
409
|
-
? structured.
|
|
421
|
+
const currentProcessId = structured?.running === true
|
|
422
|
+
? typeof structured.processId === "number"
|
|
423
|
+
? structured.processId
|
|
424
|
+
: typeof structured.sessionId === "number"
|
|
425
|
+
? structured.sessionId
|
|
426
|
+
: undefined
|
|
410
427
|
: undefined;
|
|
411
|
-
const completed = processSessions.takeCompleted(workspaceId, undefined,
|
|
428
|
+
const completed = processSessions.takeCompleted(workspaceId, undefined, currentProcessId);
|
|
412
429
|
if (completed.length === 0)
|
|
413
430
|
return result;
|
|
414
431
|
return {
|
|
@@ -421,7 +438,8 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
|
|
|
421
438
|
}
|
|
422
439
|
function processOutputSchema() {
|
|
423
440
|
return resultOutputSchema({
|
|
424
|
-
|
|
441
|
+
processId: z.number().int().positive().optional().describe("Canonical process handle for write_stdin."),
|
|
442
|
+
sessionId: z.number().int().positive().optional().describe("Deprecated alias of processId for compatibility."),
|
|
425
443
|
running: z.boolean(),
|
|
426
444
|
exitCode: z.number().int().optional(),
|
|
427
445
|
signal: z.string().optional(),
|
|
@@ -452,6 +470,7 @@ function processToolResponse(tool, workspaceId, snapshot, summary) {
|
|
|
452
470
|
},
|
|
453
471
|
structuredContent: {
|
|
454
472
|
result,
|
|
473
|
+
processId: snapshot.processId,
|
|
455
474
|
sessionId: snapshot.sessionId,
|
|
456
475
|
running: snapshot.running,
|
|
457
476
|
exitCode: snapshot.exitCode,
|
|
@@ -476,7 +495,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
476
495
|
if (config.toolMode === "codex") {
|
|
477
496
|
registerAppTool(server, "exec_command", {
|
|
478
497
|
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
|
|
498
|
+
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
499
|
inputSchema: {
|
|
481
500
|
workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
|
|
482
501
|
cmd: z.string().min(1).describe("Shell command to execute."),
|
|
@@ -496,7 +515,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
496
515
|
.min(0)
|
|
497
516
|
.max(30_000)
|
|
498
517
|
.optional()
|
|
499
|
-
.describe("Milliseconds to wait before returning a running
|
|
518
|
+
.describe("Milliseconds to wait before returning a running process. Defaults to 10000."),
|
|
500
519
|
maxOutputTokens: z
|
|
501
520
|
.number()
|
|
502
521
|
.int()
|
|
@@ -537,7 +556,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
537
556
|
commandLength: cmd.length,
|
|
538
557
|
exitCode: snapshot.exitCode,
|
|
539
558
|
running: snapshot.running,
|
|
540
|
-
|
|
559
|
+
processId: snapshot.processId,
|
|
541
560
|
success: snapshot.running || snapshot.exitCode === 0,
|
|
542
561
|
durationMs: Math.round(performance.now() - startedAt),
|
|
543
562
|
});
|
|
@@ -557,7 +576,8 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
557
576
|
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
577
|
inputSchema: {
|
|
559
578
|
workspaceId: z.string().describe("Workspace identifier used to start the process."),
|
|
560
|
-
|
|
579
|
+
processId: z.number().int().positive().optional().describe("Canonical process identifier returned by bash or exec_command."),
|
|
580
|
+
sessionId: z.number().int().positive().optional().describe("Deprecated alias for processId. Retained for compatibility."),
|
|
561
581
|
chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."),
|
|
562
582
|
columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."),
|
|
563
583
|
rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."),
|
|
@@ -579,13 +599,14 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
579
599
|
outputSchema: processOutputSchema(),
|
|
580
600
|
...toolWidgetDescriptorMeta(config, "shell"),
|
|
581
601
|
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
582
|
-
}, async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
|
|
602
|
+
}, async ({ workspaceId, processId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
|
|
583
603
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
604
|
+
const resolvedProcessId = resolveProcessId(processId, sessionId);
|
|
584
605
|
return runToolWithHooks(hooks, {
|
|
585
606
|
tool: "write_stdin",
|
|
586
607
|
invocation: workspaceHookInvocation(workspace),
|
|
587
608
|
payload: {
|
|
588
|
-
|
|
609
|
+
processId: resolvedProcessId,
|
|
589
610
|
charactersWritten: chars?.length ?? 0,
|
|
590
611
|
columns,
|
|
591
612
|
rows,
|
|
@@ -594,7 +615,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
594
615
|
const startedAt = performance.now();
|
|
595
616
|
const snapshot = await processSessions.write({
|
|
596
617
|
workspaceId,
|
|
597
|
-
|
|
618
|
+
processId: resolvedProcessId,
|
|
598
619
|
chars,
|
|
599
620
|
columns,
|
|
600
621
|
rows,
|
|
@@ -606,12 +627,12 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
606
627
|
...workspaceLogContext(workspace, extra.sessionId),
|
|
607
628
|
exitCode: snapshot.exitCode,
|
|
608
629
|
running: snapshot.running,
|
|
609
|
-
|
|
630
|
+
processId: snapshot.processId,
|
|
610
631
|
success: snapshot.running || snapshot.exitCode === 0,
|
|
611
632
|
durationMs: Math.round(performance.now() - startedAt),
|
|
612
633
|
});
|
|
613
634
|
return processToolResponse("write_stdin", workspaceId, snapshot, {
|
|
614
|
-
|
|
635
|
+
processId: resolvedProcessId,
|
|
615
636
|
charactersWritten: chars?.length ?? 0,
|
|
616
637
|
running: snapshot.running,
|
|
617
638
|
exitCode: snapshot.exitCode,
|
|
@@ -630,9 +651,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
630
651
|
version: FORGERELAY_VERSION,
|
|
631
652
|
description: "Secure local coding workspace for MCP clients. Provides workspace-scoped file, search, edit, write, and shell tools.",
|
|
632
653
|
}, {
|
|
633
|
-
instructions: buildServerInstructions(config,
|
|
634
|
-
artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
|
|
635
|
-
}),
|
|
654
|
+
instructions: buildServerInstructions(config),
|
|
636
655
|
});
|
|
637
656
|
const currentWorkspaceAppUri = currentWorkspaceAppIdentity().uri;
|
|
638
657
|
const workspaceAppResourceMetadata = {
|
|
@@ -651,7 +670,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
651
670
|
}, async (uri, _variables, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
|
|
652
671
|
registerAppTool(server, "open_workspace", {
|
|
653
672
|
title: "Open workspace",
|
|
654
|
-
description: "Open or resume a local coding workspace.
|
|
673
|
+
description: "Open or resume a local coding workspace. Reuse the returned workspaceId for later calls. Default to checkout; use mode=\"worktree\" only when the user explicitly requests isolated or parallel Git work. Every call returns a capability fingerprint; bootstrap calls also expose project context, skills, and capability guides when needed.",
|
|
655
674
|
inputSchema: {
|
|
656
675
|
path: z
|
|
657
676
|
.string()
|
|
@@ -715,6 +734,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
715
734
|
targetBranch: z.string().optional(),
|
|
716
735
|
managed: z.boolean(),
|
|
717
736
|
})),
|
|
737
|
+
capabilityFingerprint: capabilityFingerprintOutputSchema,
|
|
738
|
+
capabilityGuides: z.array(capabilityGuideOutputSchema).optional(),
|
|
718
739
|
agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
|
|
719
740
|
availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
|
|
720
741
|
skills: z.array(workspaceSkillOutputSchema).optional(),
|
|
@@ -738,6 +759,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
738
759
|
});
|
|
739
760
|
const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
|
|
740
761
|
const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
|
|
762
|
+
const capabilityFingerprint = buildCapabilityFingerprint(config, FORGERELAY_VERSION, {
|
|
763
|
+
artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
|
|
764
|
+
});
|
|
741
765
|
if (config.widgets === "changes") {
|
|
742
766
|
await reviewCheckpoints.initializeWorkspace({
|
|
743
767
|
workspaceId: workspace.id,
|
|
@@ -751,6 +775,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
751
775
|
description: skill.description,
|
|
752
776
|
path: formatPathForPrompt(skill.filePath),
|
|
753
777
|
}));
|
|
778
|
+
const capabilityGuides = workspace.capabilityGuides.map((guide) => ({
|
|
779
|
+
name: guide.name,
|
|
780
|
+
description: guide.description,
|
|
781
|
+
whenToRead: guide.whenToRead,
|
|
782
|
+
path: formatPathForPrompt(guide.filePath),
|
|
783
|
+
}));
|
|
754
784
|
const cardAgentProviders = config.subagents ? localAgentProviders : [];
|
|
755
785
|
const cardAgents = workspace.agentProfiles.map((profile) => {
|
|
756
786
|
const summary = summarizeLocalAgentProfile(profile);
|
|
@@ -769,13 +799,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
769
799
|
path: formatAgentsPath(file.path, workspace.root),
|
|
770
800
|
}));
|
|
771
801
|
const visibleSkills = includeBootstrapContext ? cardSkills : [];
|
|
802
|
+
const visibleCapabilityGuides = includeBootstrapContext ? capabilityGuides : [];
|
|
772
803
|
const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : [];
|
|
773
804
|
const visibleAgents = includeBootstrapContext ? cardAgents : [];
|
|
774
805
|
const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
|
|
775
806
|
const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
|
|
776
807
|
const cardInstruction = config.skillsEnabled
|
|
777
|
-
? "Use this workspaceId in all subsequent tool calls for this project.
|
|
778
|
-
: "Use this workspaceId in all subsequent tool calls for this project.
|
|
808
|
+
? "Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches an available skill or capability guide, read its advertised path before proceeding."
|
|
809
|
+
: "Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches a capability guide, read its advertised path before proceeding.";
|
|
779
810
|
const instruction = workspaceReused
|
|
780
811
|
? includeBootstrapContext
|
|
781
812
|
? [
|
|
@@ -786,7 +817,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
786
817
|
: [
|
|
787
818
|
`Workspace already open as ${workspace.id}.`,
|
|
788
819
|
"Reuse this workspaceId for subsequent tool calls. This is the same directory previously opened in this conversation.",
|
|
789
|
-
"Continue following the project instructions, nested instruction files, skills, agent profiles, and diagnostics previously provided for this workspace. They remain active and are not repeated here.",
|
|
820
|
+
"Continue following the project instructions, nested instruction files, skills, capability guides, agent profiles, and diagnostics previously provided for this workspace. They remain active and are not repeated here.",
|
|
790
821
|
].join("\n\n")
|
|
791
822
|
: workspace.mode === "worktree"
|
|
792
823
|
? "Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for this isolated worktree."
|
|
@@ -811,6 +842,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
811
842
|
visibleSkills.length > 0
|
|
812
843
|
? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}`
|
|
813
844
|
: undefined,
|
|
845
|
+
visibleCapabilityGuides.length > 0
|
|
846
|
+
? `Capability guides: ${visibleCapabilityGuides.map((guide) => guide.name).join(", ")}`
|
|
847
|
+
: undefined,
|
|
814
848
|
visibleAgentProviders.some((provider) => provider.available)
|
|
815
849
|
? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map((provider) => provider.name).join(", ")}`
|
|
816
850
|
: undefined,
|
|
@@ -826,6 +860,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
826
860
|
staleWorkspaces.length > 0
|
|
827
861
|
? `Idle logical workspaces for this same physical workspace (>2 days): ${staleWorkspaces.map((stale) => `${stale.workspaceId} last-used=${stale.lastUsedAt}`).join(", ")}. Tell the user these are available to resume or explicitly close; do not clean them up automatically.`
|
|
828
862
|
: undefined,
|
|
863
|
+
`ForgeRelay ${capabilityFingerprint.version} capabilities: ${capabilityFingerprint.capabilities.join(", ")}`,
|
|
829
864
|
instruction,
|
|
830
865
|
].filter(Boolean).join("\n"),
|
|
831
866
|
},
|
|
@@ -852,6 +887,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
852
887
|
worktree: workspace.worktree,
|
|
853
888
|
worktrees: knownWorktrees,
|
|
854
889
|
staleWorkspaces,
|
|
890
|
+
capabilityFingerprint,
|
|
855
891
|
agentsFiles: cardAgentsFiles,
|
|
856
892
|
availableAgentsFiles: cardAvailableAgentsFiles,
|
|
857
893
|
skills: cardSkills,
|
|
@@ -876,8 +912,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
876
912
|
worktree: workspace.worktree,
|
|
877
913
|
worktrees: knownWorktrees,
|
|
878
914
|
staleWorkspaces,
|
|
915
|
+
capabilityFingerprint,
|
|
879
916
|
...(includeBootstrapContext
|
|
880
917
|
? {
|
|
918
|
+
capabilityGuides: visibleCapabilityGuides,
|
|
881
919
|
agentsFiles: loadedAgentsFiles,
|
|
882
920
|
availableAgentsFiles: availableAgentsFileOutputs,
|
|
883
921
|
skills: visibleSkills,
|
|
@@ -892,7 +930,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
892
930
|
});
|
|
893
931
|
registerAppTool(server, toolNames.closeWorkspace, {
|
|
894
932
|
title: "Close logical workspace",
|
|
895
|
-
description: "Release one logical
|
|
933
|
+
description: "Release one logical workspaceId after the user chooses cleanup. This does not delete checkout files. Use close_worktree to finalize and remove a managed worktree. Running or unconsumed processes prevent closure.",
|
|
896
934
|
inputSchema: {
|
|
897
935
|
workspaceId: z.string().describe("Logical workspace ID to release."),
|
|
898
936
|
},
|
|
@@ -920,7 +958,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
920
958
|
});
|
|
921
959
|
registerAppTool(server, toolNames.closeWorktree, {
|
|
922
960
|
title: "Close worktree",
|
|
923
|
-
description: "
|
|
961
|
+
description: "Finalize a managed worktree after its task is complete and verified. ForgeRelay may commit remaining changes, integrate the target branch when safe, and clean up the managed worktree. Read the managed-worktrees capability guide for advanced close, safety, and failure semantics.",
|
|
924
962
|
inputSchema: {
|
|
925
963
|
workspaceId: z
|
|
926
964
|
.string()
|
|
@@ -1001,8 +1039,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1001
1039
|
path: z
|
|
1002
1040
|
.string()
|
|
1003
1041
|
.describe(config.skillsEnabled
|
|
1004
|
-
? "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised skill path from open_workspace
|
|
1005
|
-
: "File path to read, relative to the workspace root or absolute inside the OS temp directory."),
|
|
1042
|
+
? "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised skill or capability-guide path from open_workspace, including a ~/... home-relative path."
|
|
1043
|
+
: "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised capability-guide path from open_workspace."),
|
|
1006
1044
|
offset: z
|
|
1007
1045
|
.number()
|
|
1008
1046
|
.int()
|
|
@@ -1747,7 +1785,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1747
1785
|
commandLength: command.length,
|
|
1748
1786
|
exitCode: snapshot.exitCode,
|
|
1749
1787
|
running: snapshot.running,
|
|
1750
|
-
|
|
1788
|
+
processId: snapshot.processId,
|
|
1751
1789
|
success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
|
|
1752
1790
|
durationMs: Math.round(performance.now() - startedAt),
|
|
1753
1791
|
});
|
|
@@ -1786,7 +1824,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1786
1824
|
host: config.host,
|
|
1787
1825
|
...(allowedHosts ? { allowedHosts } : {}),
|
|
1788
1826
|
});
|
|
1789
|
-
const transports = new
|
|
1827
|
+
const transports = new McpTransportRegistry();
|
|
1790
1828
|
const mcpUrl = new URL("/mcp", config.publicBaseUrl);
|
|
1791
1829
|
const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
|
|
1792
1830
|
const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir);
|
|
@@ -1798,17 +1836,17 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1798
1836
|
const workspaceStore = createWorkspaceStore(config.stateDir);
|
|
1799
1837
|
const workspaces = new WorkspaceRegistry(config, workspaceStore);
|
|
1800
1838
|
const reviewCheckpoints = createReviewCheckpointManager();
|
|
1801
|
-
const processSessions = new
|
|
1839
|
+
const processSessions = new ProcessManager();
|
|
1802
1840
|
const localAgentProviders = config.subagents
|
|
1803
1841
|
? getLocalAgentProviderAvailabilitySnapshot()
|
|
1804
1842
|
: [];
|
|
1805
|
-
const
|
|
1843
|
+
const logTransportCloseResults = (reason, results) => {
|
|
1806
1844
|
let closedCount = 0;
|
|
1807
1845
|
for (const result of results) {
|
|
1808
1846
|
if (result.error) {
|
|
1809
|
-
logEvent(config.logging, "warn", "
|
|
1847
|
+
logEvent(config.logging, "warn", "mcp_transport_session_close_failed", {
|
|
1810
1848
|
reason,
|
|
1811
|
-
|
|
1849
|
+
transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
|
|
1812
1850
|
error: result.error instanceof Error
|
|
1813
1851
|
? result.error.message
|
|
1814
1852
|
: String(result.error),
|
|
@@ -1817,27 +1855,27 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1817
1855
|
}
|
|
1818
1856
|
closedCount += 1;
|
|
1819
1857
|
if (reason === "idle_timeout") {
|
|
1820
|
-
logEvent(config.logging, "debug", "
|
|
1858
|
+
logEvent(config.logging, "debug", "mcp_transport_session_closed", {
|
|
1821
1859
|
reason,
|
|
1822
|
-
|
|
1860
|
+
transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
|
|
1823
1861
|
});
|
|
1824
1862
|
}
|
|
1825
1863
|
}
|
|
1826
1864
|
if (reason === "server_shutdown" && closedCount > 0) {
|
|
1827
|
-
logEvent(config.logging, "debug", "
|
|
1865
|
+
logEvent(config.logging, "debug", "mcp_transport_sessions_closed", {
|
|
1828
1866
|
reason,
|
|
1829
1867
|
count: closedCount,
|
|
1830
1868
|
});
|
|
1831
1869
|
}
|
|
1832
1870
|
};
|
|
1833
|
-
const
|
|
1871
|
+
const transportCleanupTimer = setInterval(() => {
|
|
1834
1872
|
void transports
|
|
1835
|
-
.closeIdle(
|
|
1836
|
-
.then((results) =>
|
|
1837
|
-
},
|
|
1838
|
-
|
|
1873
|
+
.closeIdle(MCP_TRANSPORT_IDLE_TIMEOUT_MS)
|
|
1874
|
+
.then((results) => logTransportCloseResults("idle_timeout", results));
|
|
1875
|
+
}, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
|
|
1876
|
+
transportCleanupTimer.unref();
|
|
1839
1877
|
if (config.logging.trustProxy) {
|
|
1840
|
-
app.set("trust proxy",
|
|
1878
|
+
app.set("trust proxy", 1);
|
|
1841
1879
|
}
|
|
1842
1880
|
app.use((req, res, next) => {
|
|
1843
1881
|
const requestId = randomUUID();
|
|
@@ -1883,7 +1921,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1883
1921
|
});
|
|
1884
1922
|
app.all("/mcp", async (req, res) => {
|
|
1885
1923
|
const requestId = res.locals.requestId;
|
|
1886
|
-
const
|
|
1924
|
+
const transportSessionId = req.header("mcp-session-id");
|
|
1887
1925
|
const initializeRequest = req.method === "POST" && isInitializeRequest(req.body);
|
|
1888
1926
|
await new Promise((resolve, reject) => {
|
|
1889
1927
|
bearerAuth(req, res, (error) => {
|
|
@@ -1909,39 +1947,39 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1909
1947
|
logEvent(config.logging, "debug", "mcp_request", {
|
|
1910
1948
|
requestId,
|
|
1911
1949
|
httpMethod: req.method,
|
|
1912
|
-
|
|
1913
|
-
|
|
1950
|
+
transportSessionIdPresent: Boolean(transportSessionId),
|
|
1951
|
+
transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
|
|
1914
1952
|
isInitialize: initializeRequest,
|
|
1915
1953
|
...mcpRequestDebugFields(req.body),
|
|
1916
1954
|
});
|
|
1917
1955
|
try {
|
|
1918
1956
|
let transport;
|
|
1919
|
-
if (
|
|
1920
|
-
transport = transports.get(
|
|
1957
|
+
if (transportSessionId) {
|
|
1958
|
+
transport = transports.get(transportSessionId);
|
|
1921
1959
|
if (!transport) {
|
|
1922
|
-
sendJsonRpcError(res, 404, -32000, "Unknown MCP session");
|
|
1960
|
+
sendJsonRpcError(res, 404, -32000, "Unknown MCP transport session");
|
|
1923
1961
|
return;
|
|
1924
1962
|
}
|
|
1925
1963
|
}
|
|
1926
1964
|
else if (initializeRequest) {
|
|
1927
1965
|
transport = new StreamableHTTPServerTransport({
|
|
1928
1966
|
sessionIdGenerator: () => randomUUID(),
|
|
1929
|
-
onsessioninitialized: (
|
|
1967
|
+
onsessioninitialized: (newTransportSessionId) => {
|
|
1930
1968
|
if (transport)
|
|
1931
|
-
transports.register(
|
|
1932
|
-
logEvent(config.logging, "debug", "
|
|
1969
|
+
transports.register(newTransportSessionId, transport);
|
|
1970
|
+
logEvent(config.logging, "debug", "mcp_transport_session_created", {
|
|
1933
1971
|
requestId,
|
|
1934
|
-
|
|
1972
|
+
transportSessionIdPrefix: transportSessionIdPrefix(newTransportSessionId),
|
|
1935
1973
|
...requestLogFields(req, config),
|
|
1936
1974
|
});
|
|
1937
1975
|
},
|
|
1938
1976
|
});
|
|
1939
1977
|
transport.onclose = () => {
|
|
1940
|
-
const
|
|
1941
|
-
if (
|
|
1942
|
-
logEvent(config.logging, "debug", "
|
|
1978
|
+
const closedTransportSessionId = transport?.sessionId;
|
|
1979
|
+
if (closedTransportSessionId && transports.remove(closedTransportSessionId)) {
|
|
1980
|
+
logEvent(config.logging, "debug", "mcp_transport_session_closed", {
|
|
1943
1981
|
reason: "transport_close",
|
|
1944
|
-
|
|
1982
|
+
transportSessionIdPrefix: transportSessionIdPrefix(closedTransportSessionId),
|
|
1945
1983
|
});
|
|
1946
1984
|
}
|
|
1947
1985
|
};
|
|
@@ -1949,7 +1987,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1949
1987
|
await server.connect(transport);
|
|
1950
1988
|
}
|
|
1951
1989
|
else {
|
|
1952
|
-
sendJsonRpcError(res, 400, -32000, "No valid MCP session");
|
|
1990
|
+
sendJsonRpcError(res, 400, -32000, "No valid MCP transport session");
|
|
1953
1991
|
return;
|
|
1954
1992
|
}
|
|
1955
1993
|
await transport.handleRequest(req, res, req.body);
|
|
@@ -1971,9 +2009,9 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1971
2009
|
localAgentProviders,
|
|
1972
2010
|
close: () => {
|
|
1973
2011
|
closePromise ??= (async () => {
|
|
1974
|
-
clearInterval(
|
|
2012
|
+
clearInterval(transportCleanupTimer);
|
|
1975
2013
|
const results = await transports.closeAll();
|
|
1976
|
-
|
|
2014
|
+
logTransportCloseResults("server_shutdown", results);
|
|
1977
2015
|
processSessions.shutdown();
|
|
1978
2016
|
oauthProvider.close();
|
|
1979
2017
|
workspaceStore.close?.();
|
package/dist/skills.js
CHANGED
|
@@ -1,27 +1,16 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve, sep } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { markAdvertisedFileSourceActivated, resolveAdvertisedFileReadPath, } from "./advertised-files.js";
|
|
5
5
|
import { loadSkills, } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { expandHomePath
|
|
6
|
+
import { expandHomePath } from "./roots.js";
|
|
7
7
|
const SUBAGENT_DELEGATION_NAME = "subagent-delegation";
|
|
8
|
-
const SUBAGENT_DELEGATION_SKILL = join(SUBAGENT_DELEGATION_NAME, "SKILL.md");
|
|
9
|
-
function bundledSkillsDir() {
|
|
10
|
-
return fileURLToPath(new URL("../skills", import.meta.url));
|
|
11
|
-
}
|
|
12
|
-
function hasSubagentDelegationSkill(skillDir) {
|
|
13
|
-
return existsSync(join(skillDir, SUBAGENT_DELEGATION_SKILL));
|
|
14
|
-
}
|
|
15
8
|
export function effectiveSkillPaths(config, cwd) {
|
|
16
|
-
const bundledSkills = bundledSkillsDir();
|
|
17
9
|
const defaultPathCandidates = [
|
|
18
10
|
join(homedir(), ".agents", "skills"),
|
|
19
11
|
resolve(cwd, ".agents", "skills"),
|
|
20
12
|
config.devspaceSkillsDir,
|
|
21
13
|
join(config.agentDir, "skills"),
|
|
22
|
-
config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir)
|
|
23
|
-
? bundledSkills
|
|
24
|
-
: undefined,
|
|
25
14
|
];
|
|
26
15
|
const defaultPaths = defaultPathCandidates.filter((path) => path !== undefined && existsSync(path));
|
|
27
16
|
const seen = new Set();
|
|
@@ -57,25 +46,17 @@ export function loadWorkspaceSkills(config, cwd) {
|
|
|
57
46
|
};
|
|
58
47
|
}
|
|
59
48
|
export function resolveSkillReadPath(skills, activatedSkillDirs, inputPath) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const baseDir = resolve(skill.baseDir);
|
|
69
|
-
if (!activatedSkillDirs.has(baseDir))
|
|
70
|
-
continue;
|
|
71
|
-
if (!isPathInsideRoot(absolutePath, baseDir))
|
|
72
|
-
continue;
|
|
73
|
-
return { absolutePath, skill, isSkillFile: false };
|
|
74
|
-
}
|
|
75
|
-
return undefined;
|
|
49
|
+
const resolution = resolveAdvertisedFileReadPath(skills, activatedSkillDirs, inputPath);
|
|
50
|
+
if (!resolution)
|
|
51
|
+
return undefined;
|
|
52
|
+
return {
|
|
53
|
+
absolutePath: resolution.absolutePath,
|
|
54
|
+
skill: resolution.source,
|
|
55
|
+
isSkillFile: resolution.isEntryFile,
|
|
56
|
+
};
|
|
76
57
|
}
|
|
77
58
|
export function markSkillActivated(activatedSkillDirs, skill) {
|
|
78
|
-
activatedSkillDirs
|
|
59
|
+
markAdvertisedFileSourceActivated(activatedSkillDirs, skill);
|
|
79
60
|
}
|
|
80
61
|
export function formatPathForPrompt(path) {
|
|
81
62
|
const home = resolve(homedir());
|
package/dist/workspaces.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { loadCapabilityGuides, markCapabilityGuideActivated, resolveCapabilityGuideReadPath, } from "./capabilities.js";
|
|
5
6
|
import { HookRunner } from "./hooks.js";
|
|
6
7
|
import { closeManagedWorktree, createManagedWorktree, resolveManagedWorktreeBase, } from "./git-worktrees.js";
|
|
7
8
|
import { AccessDeniedError, assertAllowedPath, isPathInsideRoot, resolveAllowedPath, } from "./roots.js";
|
|
@@ -548,8 +549,10 @@ export class WorkspaceRegistry {
|
|
|
548
549
|
}
|
|
549
550
|
: undefined,
|
|
550
551
|
...this.loadSkillsForWorkspace(root),
|
|
552
|
+
capabilityGuides: loadCapabilityGuides(this.config),
|
|
551
553
|
agentProfiles: [],
|
|
552
554
|
activatedSkillDirs: new Set(),
|
|
555
|
+
activatedCapabilityGuideDirs: new Set(),
|
|
553
556
|
};
|
|
554
557
|
if (touch)
|
|
555
558
|
this.store?.touchSession(session.id);
|
|
@@ -582,6 +585,14 @@ export class WorkspaceRegistry {
|
|
|
582
585
|
skillRead,
|
|
583
586
|
};
|
|
584
587
|
}
|
|
588
|
+
const capabilityGuideRead = resolveCapabilityGuideReadPath(workspace.capabilityGuides, workspace.activatedCapabilityGuideDirs, inputPath);
|
|
589
|
+
if (capabilityGuideRead) {
|
|
590
|
+
return {
|
|
591
|
+
absolutePath: capabilityGuideRead.absolutePath,
|
|
592
|
+
readRoots: [workspace.root, capabilityGuideRead.guide.baseDir],
|
|
593
|
+
capabilityGuideRead,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
585
596
|
try {
|
|
586
597
|
return {
|
|
587
598
|
absolutePath: resolveAllowedPath(inputPath, workspace.root, [tmpdir()]),
|
|
@@ -597,6 +608,9 @@ export class WorkspaceRegistry {
|
|
|
597
608
|
if (readPath.skillRead?.isSkillFile) {
|
|
598
609
|
markSkillActivated(workspace.activatedSkillDirs, readPath.skillRead.skill);
|
|
599
610
|
}
|
|
611
|
+
if (readPath.capabilityGuideRead?.isGuideFile) {
|
|
612
|
+
markCapabilityGuideActivated(workspace.activatedCapabilityGuideDirs, readPath.capabilityGuideRead.guide);
|
|
613
|
+
}
|
|
600
614
|
}
|
|
601
615
|
resolveWorkingDirectory(workspace, workingDirectory) {
|
|
602
616
|
const directory = workingDirectory ? this.resolvePath(workspace, workingDirectory) : workspace.root;
|
|
@@ -631,8 +645,10 @@ export class WorkspaceRegistry {
|
|
|
631
645
|
sourceRoot: input.sourceRoot,
|
|
632
646
|
worktree: input.worktree,
|
|
633
647
|
...this.loadSkillsForWorkspace(input.root),
|
|
648
|
+
capabilityGuides: loadCapabilityGuides(this.config),
|
|
634
649
|
agentProfiles: await loadLocalAgentProfiles(this.config, input.root),
|
|
635
650
|
activatedSkillDirs: new Set(),
|
|
651
|
+
activatedCapabilityGuideDirs: new Set(),
|
|
636
652
|
};
|
|
637
653
|
this.store?.createSession({
|
|
638
654
|
id: workspace.id,
|