@akira-tl/forgerelay 1.2.5 → 1.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 +20 -0
- package/README.md +2 -2
- package/dist/cli/config/domains/context-cli.js +86 -0
- package/dist/cli/config/domains/domain-cli.js +468 -0
- package/dist/cli/config/general.js +174 -0
- package/dist/cli/config/inspect.js +29 -26
- package/dist/cli/config/migrate.js +6 -22
- package/dist/cli/config/scope.js +35 -0
- package/dist/cli/connect/relay.js +284 -0
- package/dist/cli/core/command-tree.js +68 -0
- package/dist/cli/core/serve-options.js +71 -0
- package/dist/cli/init/setup-config.js +26 -0
- package/dist/cli/init.js +37 -8
- package/dist/cli/maintenance-prune.js +1 -1
- package/dist/cli/maintenance.js +6 -6
- package/dist/cli/mcp/external-mcp.js +29 -17
- package/dist/cli/mcp/status.js +2 -2
- package/dist/cli/system/status.js +35 -0
- package/dist/cli.js +132 -272
- package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +2 -2
- package/dist/mcp/server/core/schemas.js +2 -10
- package/dist/mcp/server/operations/runtime/operation-runtime.js +11 -7
- package/dist/runtime/config/config.js +30 -29
- package/dist/runtime/config/definition/general-config.js +18 -3
- package/dist/runtime/config/resolution/resolver.js +7 -4
- package/dist/runtime/config/user-config.js +6 -6
- package/dist/runtime/config/validation/paths.js +12 -0
- package/dist/subagents/profiles.js +37 -0
- package/dist/workspaces/bootstrap.js +31 -14
- package/dist/workspaces/context.js +159 -7
- package/dist/workspaces/relay/auth/cli-test-support.js +22 -0
- package/dist/workspaces/resources/context-sources.js +29 -0
- package/dist/workspaces/resources/resource-monitor.js +29 -6
- package/dist/workspaces/resources/skills.js +15 -10
- package/dist/workspaces/sessions.js +4 -2
- package/dist/workspaces/state/project-context.js +34 -8
- package/dist/workspaces.js +5 -2
- package/docs/chatgpt-coding-workflow.md +23 -20
- package/docs/configuration.md +40 -27
- package/docs/gotchas.md +8 -6
- package/docs/roadmap.md +1 -1
- package/package.json +2 -2
- package/schemas/v1/config.project-local.schema.json +74 -0
- package/schemas/v1/config.project.schema.json +74 -0
- package/schemas/v1/config.user.schema.json +57 -2
- package/scripts/ci/config-v2-product-acceptance.mjs +17 -12
- package/scripts/debug/runtime.mjs +19 -3
- package/scripts/debug/runtime.test.mjs +3 -0
- package/scripts/debug/serve.mjs +2 -2
|
@@ -2,11 +2,12 @@ import { isIP } from "node:net";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { expandHomePath } from "../../mcp/filesystem/roots.js";
|
|
5
|
-
import {
|
|
5
|
+
import { normalizeAllowedRootPaths } from "./validation/paths.js";
|
|
6
|
+
import { generateInstanceId, loadForgeRelayFiles, } from "./user-config.js";
|
|
6
7
|
import { DEFAULT_MEDIA_MAX_BYTES } from "../../mcp/operations/media-content.js";
|
|
7
8
|
import { shellInstructionPath } from "../instructions/shell-instructions.js";
|
|
8
9
|
import { resolveConfiguredCommandShellRuntime, } from "../shell/command-shell-runtime.js";
|
|
9
|
-
import { generalConfigDefinition } from "./definition/general-config.js";
|
|
10
|
+
import { DEFAULT_INSTRUCTION_NAMES, DEFAULT_SKILL_PATHS, generalConfigDefinition, } from "./definition/general-config.js";
|
|
10
11
|
import { resolveGeneralConfig } from "./resolution/general.js";
|
|
11
12
|
import { assertConfigResolutionValid } from "./resolution/resolver.js";
|
|
12
13
|
import { ConfigRuntime } from "./runtime/config-runtime.js";
|
|
@@ -15,16 +16,8 @@ const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
|
15
16
|
const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
16
17
|
const DEFAULT_TASK_REMINDER_INTERVAL = 30;
|
|
17
18
|
function parseAllowedRoots(value) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return (roots.length > 0 ? roots : [process.cwd()]).map((root) => resolve(expandHomePath(root)));
|
|
21
|
-
}
|
|
22
|
-
const rawRoots = value
|
|
23
|
-
?.split(",")
|
|
24
|
-
.map((entry) => entry.trim())
|
|
25
|
-
.filter(Boolean) ?? [];
|
|
26
|
-
const roots = rawRoots.length > 0 ? rawRoots : [process.cwd()];
|
|
27
|
-
return roots.map((root) => resolve(expandHomePath(root)));
|
|
19
|
+
const roots = Array.isArray(value) ? value : value?.split(",") ?? [];
|
|
20
|
+
return normalizeAllowedRootPaths(roots);
|
|
28
21
|
}
|
|
29
22
|
function parseAllowedHosts(value, derivedHosts) {
|
|
30
23
|
if (Array.isArray(value)) {
|
|
@@ -85,12 +78,6 @@ function parseLogFormat(value) {
|
|
|
85
78
|
return "json";
|
|
86
79
|
throw new Error(`Invalid FORGERELAY_LOG_FORMAT: ${value}`);
|
|
87
80
|
}
|
|
88
|
-
function parsePathList(value) {
|
|
89
|
-
return (value
|
|
90
|
-
?.split(",")
|
|
91
|
-
.map((entry) => entry.trim())
|
|
92
|
-
.filter(Boolean) ?? []);
|
|
93
|
-
}
|
|
94
81
|
function parseStringList(value, fallback) {
|
|
95
82
|
const entries = value
|
|
96
83
|
?.split(",")
|
|
@@ -244,13 +231,7 @@ function resolvePublicDeployment(configuredValue, host, port) {
|
|
|
244
231
|
canonicalBaseUrl: baseUrls[0],
|
|
245
232
|
};
|
|
246
233
|
}
|
|
247
|
-
|
|
248
|
-
const configRuntime = new ConfigRuntime();
|
|
249
|
-
const runtimeEnvironment = generalConfigRuntimeEnvironment(env);
|
|
250
|
-
configRuntime.captureResolutionInputs(generalConfigDefinition.domain, {
|
|
251
|
-
environment: runtimeEnvironment,
|
|
252
|
-
...(options.runtimeOverrides ? { cli: options.runtimeOverrides } : {}),
|
|
253
|
-
});
|
|
234
|
+
function resolveGeneralRuntimeConfig(env, options = {}) {
|
|
254
235
|
const files = loadForgeRelayFiles(env);
|
|
255
236
|
const generalResolution = resolveGeneralConfig({
|
|
256
237
|
env,
|
|
@@ -265,11 +246,31 @@ export function loadConfig(env = process.env, options = {}) {
|
|
|
265
246
|
});
|
|
266
247
|
assertConfigResolutionValid(generalResolution);
|
|
267
248
|
const config = generalResolution.values;
|
|
268
|
-
refreshGeneralUserConfigSource(configRuntime, files.configPath, runtimeEnvironment);
|
|
269
|
-
const instanceId = files.auth.instanceId?.trim() || generateInstanceId();
|
|
270
249
|
const host = config.host ?? "127.0.0.1";
|
|
271
250
|
const port = config.port ?? 7676;
|
|
272
251
|
const publicDeployment = resolvePublicDeployment(config.publicBaseUrl, host, port);
|
|
252
|
+
return { files, config, host, port, publicDeployment };
|
|
253
|
+
}
|
|
254
|
+
export function resolveRuntimeConfigFacts(env = process.env) {
|
|
255
|
+
const { files, config, host, port, publicDeployment } = resolveGeneralRuntimeConfig(env);
|
|
256
|
+
return {
|
|
257
|
+
...(files.auth.instanceId?.trim() ? { instanceId: files.auth.instanceId.trim() } : {}),
|
|
258
|
+
stateDir: resolve(expandHomePath(config.stateDir ?? defaultStateDir())),
|
|
259
|
+
host,
|
|
260
|
+
port,
|
|
261
|
+
publicBaseUrl: publicDeployment.canonicalBaseUrl,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
export function loadConfig(env = process.env, options = {}) {
|
|
265
|
+
const configRuntime = new ConfigRuntime();
|
|
266
|
+
const runtimeEnvironment = generalConfigRuntimeEnvironment(env);
|
|
267
|
+
configRuntime.captureResolutionInputs(generalConfigDefinition.domain, {
|
|
268
|
+
environment: runtimeEnvironment,
|
|
269
|
+
...(options.runtimeOverrides ? { cli: options.runtimeOverrides } : {}),
|
|
270
|
+
});
|
|
271
|
+
const { files, config, host, port, publicDeployment } = resolveGeneralRuntimeConfig(env, options);
|
|
272
|
+
refreshGeneralUserConfigSource(configRuntime, files.configPath, runtimeEnvironment);
|
|
273
|
+
const instanceId = files.auth.instanceId?.trim() || generateInstanceId();
|
|
273
274
|
const publicBaseUrl = publicDeployment.canonicalBaseUrl;
|
|
274
275
|
const proxyTrust = resolveProxyTrust(env, config, host, publicBaseUrl);
|
|
275
276
|
const commandShellRuntime = resolveConfiguredCommandShellRuntime(config.commandShell, process.platform, env);
|
|
@@ -305,8 +306,8 @@ export function loadConfig(env = process.env, options = {}) {
|
|
|
305
306
|
mediaMaxBytes: config.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES,
|
|
306
307
|
taskReminderInterval: config.taskReminderInterval ?? DEFAULT_TASK_REMINDER_INTERVAL,
|
|
307
308
|
skillsEnabled: productEnv(env, "SKILLS") === undefined ? true : parseBoolean(productEnv(env, "SKILLS")),
|
|
308
|
-
skillPaths:
|
|
309
|
-
|
|
309
|
+
skillPaths: config.skillPaths ?? [...DEFAULT_SKILL_PATHS],
|
|
310
|
+
instructionNames: config.instructionNames ?? [...DEFAULT_INSTRUCTION_NAMES],
|
|
310
311
|
subagents: config.subagents === true,
|
|
311
312
|
allowAgentLanguageServerInstall: config.allowAgentLanguageServerInstall === true,
|
|
312
313
|
agentDir: resolve(expandHomePath(config.agentDir ?? defaultAgentDir())),
|
|
@@ -2,7 +2,11 @@ import * as z from "zod/v4";
|
|
|
2
2
|
import { defineConfigDomain } from "./definition.js";
|
|
3
3
|
import { LISTEN_PORT_MIN, PORT_MAX } from "../validation/ports.js";
|
|
4
4
|
const USER_RUNTIME_SCOPES = ["runtime", "user", "built-in"];
|
|
5
|
+
const CONTEXT_SOURCE_SCOPES = ["runtime", "project-local", "project", "user", "built-in"];
|
|
5
6
|
const USER_SCOPES = ["user", "built-in"];
|
|
7
|
+
export const DEFAULT_SYSTEM_INSTRUCTIONS_PATH = "~/.agents/AGENTS.md";
|
|
8
|
+
export const DEFAULT_INSTRUCTION_NAMES = ["AGENTS.md"];
|
|
9
|
+
export const DEFAULT_SKILL_PATHS = ["~/.agents/skills", "./.agents/skills"];
|
|
6
10
|
const USER_ONLY_SCOPES = ["user"];
|
|
7
11
|
const LEGACY_REMOVAL_VERSION = "1.4.0";
|
|
8
12
|
const commandShellSchema = z.object({
|
|
@@ -14,6 +18,7 @@ const retentionSchema = z.object({
|
|
|
14
18
|
historyDays: z.number().int().min(1).max(36_500).optional(),
|
|
15
19
|
orphanedAdministrativeState: z.boolean().optional(),
|
|
16
20
|
}).strict();
|
|
21
|
+
const instructionNameSchema = z.string().trim().min(1).refine((value) => value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\"), "Instruction name must be one basename without path separators.");
|
|
17
22
|
export const generalConfigDefinition = defineConfigDomain({
|
|
18
23
|
domain: "config",
|
|
19
24
|
title: "ForgeRelay general configuration",
|
|
@@ -113,11 +118,21 @@ export const generalConfigDefinition = defineConfigDomain({
|
|
|
113
118
|
reload: "restart-required",
|
|
114
119
|
runtimeOverride: runtimeEnv("FORGERELAY_AGENT_DIR", (env) => env.FORGERELAY_AGENT_DIR),
|
|
115
120
|
}),
|
|
116
|
-
systemInstructionsPath: field(z.string().min(1), "
|
|
117
|
-
scopes:
|
|
118
|
-
builtIn:
|
|
121
|
+
systemInstructionsPath: field(z.string().trim().min(1), "Selected system-level Agent instruction file consumed by ForgeRelay.", {
|
|
122
|
+
scopes: CONTEXT_SOURCE_SCOPES,
|
|
123
|
+
builtIn: literal(DEFAULT_SYSTEM_INSTRUCTIONS_PATH),
|
|
119
124
|
runtimeOverride: runtimeEnv("FORGERELAY_SYSTEM_INSTRUCTIONS_PATH", (env) => readNonEmptyPathEnv(env, "FORGERELAY_SYSTEM_INSTRUCTIONS_PATH")),
|
|
120
125
|
}),
|
|
126
|
+
instructionNames: field(z.array(instructionNameSchema), "Project instruction basenames discovered hierarchically within a Workspace.", {
|
|
127
|
+
scopes: CONTEXT_SOURCE_SCOPES,
|
|
128
|
+
builtIn: literal([...DEFAULT_INSTRUCTION_NAMES]),
|
|
129
|
+
runtimeOverride: runtimeEnv("FORGERELAY_INSTRUCTION_NAMES", (env) => readListEnv(env, "FORGERELAY_INSTRUCTION_NAMES")),
|
|
130
|
+
}),
|
|
131
|
+
skillPaths: field(z.array(z.string().trim().min(1)), "Ordered Agent Skill source directories; explicit higher-precedence lists replace lower-precedence lists.", {
|
|
132
|
+
scopes: CONTEXT_SOURCE_SCOPES,
|
|
133
|
+
builtIn: literal([...DEFAULT_SKILL_PATHS]),
|
|
134
|
+
runtimeOverride: runtimeEnv("FORGERELAY_SKILL_PATHS", (env) => readListEnv(env, "FORGERELAY_SKILL_PATHS")),
|
|
135
|
+
}),
|
|
121
136
|
commandShell: field(commandShellSchema, "Recorded command-shell preference for ForgeRelay command and Hook execution.", {
|
|
122
137
|
scopes: USER_SCOPES,
|
|
123
138
|
builtIn: computed("Platform compatibility default unless an explicit shell preference is recorded."),
|
|
@@ -367,8 +367,7 @@ function appendFieldDeprecations(diagnostics, field, candidates, logicalPath) {
|
|
|
367
367
|
code: "deprecated_source",
|
|
368
368
|
source: candidate.source,
|
|
369
369
|
logicalPath,
|
|
370
|
-
message:
|
|
371
|
-
(field.deprecation.replacement ? `; use ${field.deprecation.replacement}.` : "."),
|
|
370
|
+
message: deprecationMessage(logicalPath, field.deprecation),
|
|
372
371
|
});
|
|
373
372
|
}
|
|
374
373
|
}
|
|
@@ -394,10 +393,14 @@ function sourceDeprecationDiagnostic(source, deprecation) {
|
|
|
394
393
|
severity: "warning",
|
|
395
394
|
code: "deprecated_source",
|
|
396
395
|
source,
|
|
397
|
-
message: `Configuration source ${source.location ?? source.id}
|
|
398
|
-
(deprecation.replacement ? `; use ${deprecation.replacement}.` : "."),
|
|
396
|
+
message: deprecationMessage(`Configuration source ${source.location ?? source.id}`, deprecation),
|
|
399
397
|
};
|
|
400
398
|
}
|
|
399
|
+
function deprecationMessage(subject, deprecation) {
|
|
400
|
+
return `${subject} is deprecated since ForgeRelay ${deprecation.since}` +
|
|
401
|
+
(deprecation.removeIn ? ` and will be removed in ForgeRelay ${deprecation.removeIn}` : "") +
|
|
402
|
+
(deprecation.replacement ? `; use ${deprecation.replacement}.` : ".");
|
|
403
|
+
}
|
|
401
404
|
function diagnosticForSourceError(source, error) {
|
|
402
405
|
const missingEnvironment = missingEnvironmentName(error);
|
|
403
406
|
if (missingEnvironment) {
|
|
@@ -25,9 +25,6 @@ export function forgerelayHooksPath(env = process.env) {
|
|
|
25
25
|
export function forgerelayHooksDir(env = process.env) {
|
|
26
26
|
return join(forgerelayConfigDir(env), "hooks");
|
|
27
27
|
}
|
|
28
|
-
export function forgerelaySkillsDir(env = process.env) {
|
|
29
|
-
return join(forgerelayConfigDir(env), "skills");
|
|
30
|
-
}
|
|
31
28
|
export function loadForgeRelayFiles(env = process.env, options = {}) {
|
|
32
29
|
const dir = forgerelayConfigDir(env);
|
|
33
30
|
const configPath = join(dir, "config.json");
|
|
@@ -53,7 +50,7 @@ export function writeForgeRelayConfig(config, env = process.env) {
|
|
|
53
50
|
const filePath = forgerelayConfigPath(env);
|
|
54
51
|
const validated = parseConfigSource(generalConfigDefinition, "user", config);
|
|
55
52
|
mkdirSync(forgerelayConfigDir(env), { recursive: true });
|
|
56
|
-
|
|
53
|
+
writeConfigJsonFile(filePath, validated, 0o600);
|
|
57
54
|
return filePath;
|
|
58
55
|
}
|
|
59
56
|
export async function writeForgeRelayAuth(auth, env = process.env) {
|
|
@@ -188,10 +185,13 @@ async function replaceAuthFile(tempPath, filePath) {
|
|
|
188
185
|
function delay(ms) {
|
|
189
186
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
190
187
|
}
|
|
191
|
-
function
|
|
188
|
+
export function writeConfigJsonFile(filePath, value, mode) {
|
|
189
|
+
writeConfigTextFile(filePath, JSON.stringify(value, null, 2) + "\n", mode);
|
|
190
|
+
}
|
|
191
|
+
export function writeConfigTextFile(filePath, value, mode) {
|
|
192
192
|
const tempPath = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
193
193
|
try {
|
|
194
|
-
writeFileSync(tempPath,
|
|
194
|
+
writeFileSync(tempPath, value, { mode });
|
|
195
195
|
renameSync(tempPath, filePath);
|
|
196
196
|
}
|
|
197
197
|
finally {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { expandHomePath } from "../../../mcp/filesystem/roots.js";
|
|
3
|
+
export function normalizeAllowedRootPath(value) {
|
|
4
|
+
const root = value.trim();
|
|
5
|
+
if (!root)
|
|
6
|
+
throw new Error("Allowed root must be a non-empty path.");
|
|
7
|
+
return resolve(expandHomePath(root));
|
|
8
|
+
}
|
|
9
|
+
export function normalizeAllowedRootPaths(values, fallback = [process.cwd()]) {
|
|
10
|
+
const roots = values.map((entry) => entry.trim()).filter(Boolean);
|
|
11
|
+
return (roots.length > 0 ? roots : fallback).map(normalizeAllowedRootPath);
|
|
12
|
+
}
|
|
@@ -307,6 +307,43 @@ function legacyProfileFromDocument(content, filePath) {
|
|
|
307
307
|
const parsed = parseFrontmatter(content, filePath);
|
|
308
308
|
return legacyProfileFromFrontmatter(parsed.frontmatter, parsed.body, filePath);
|
|
309
309
|
}
|
|
310
|
+
export function canonicalSubagentProfileValueFromDocument(content, filePath) {
|
|
311
|
+
const profile = canonicalProfileFromDocument(content, filePath);
|
|
312
|
+
if (profile.disabled)
|
|
313
|
+
return { disabled: true };
|
|
314
|
+
return {
|
|
315
|
+
description: profile.description,
|
|
316
|
+
provider: profile.provider,
|
|
317
|
+
...(profile.model ? { model: profile.model } : {}),
|
|
318
|
+
...(profile.thinking ? { thinking: profile.thinking } : {}),
|
|
319
|
+
body: profile.body,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
export function canonicalSubagentProfileDocument(name, value) {
|
|
323
|
+
const normalizedName = profileNameSchema.parse(name);
|
|
324
|
+
const disabled = subagentProfileDisabledSchema.safeParse(value);
|
|
325
|
+
if (disabled.success) {
|
|
326
|
+
return [
|
|
327
|
+
FRONTMATTER_DELIMITER,
|
|
328
|
+
`name: ${JSON.stringify(normalizedName)}`,
|
|
329
|
+
"disabled: true",
|
|
330
|
+
FRONTMATTER_DELIMITER,
|
|
331
|
+
"",
|
|
332
|
+
].join("\n");
|
|
333
|
+
}
|
|
334
|
+
const profile = subagentProfileValueSchema.parse(value);
|
|
335
|
+
return [
|
|
336
|
+
FRONTMATTER_DELIMITER,
|
|
337
|
+
`name: ${JSON.stringify(normalizedName)}`,
|
|
338
|
+
`description: ${JSON.stringify(profile.description)}`,
|
|
339
|
+
`provider: ${profile.provider}`,
|
|
340
|
+
...(profile.model ? [`model: ${JSON.stringify(profile.model)}`] : []),
|
|
341
|
+
...(profile.thinking ? [`thinking: ${JSON.stringify(profile.thinking)}`] : []),
|
|
342
|
+
FRONTMATTER_DELIMITER,
|
|
343
|
+
profile.body,
|
|
344
|
+
"",
|
|
345
|
+
].join("\n");
|
|
346
|
+
}
|
|
310
347
|
export function canonicalSubagentProfileDocumentFromLegacy(content, filePath) {
|
|
311
348
|
const profile = legacyProfileFromDocument(content, filePath);
|
|
312
349
|
const frontmatter = [
|
|
@@ -22,21 +22,38 @@ export function resolveBootstrapContextComponents(mode, currentFingerprints, del
|
|
|
22
22
|
return BOOTSTRAP_CONTEXT_COMPONENTS.filter((component) => !deliveries.some((delivery) => delivery.componentFingerprints?.[component] === currentFingerprints[component]));
|
|
23
23
|
}
|
|
24
24
|
export function bootstrapContextFingerprints(workspace, agentsFiles, availableAgentsFiles) {
|
|
25
|
+
const instructionSources = {
|
|
26
|
+
systemInstructionsPath: resolve(workspace.contextSources.systemInstructionsPath),
|
|
27
|
+
instructionNames: [...workspace.contextSources.instructionNames],
|
|
28
|
+
};
|
|
29
|
+
const skillSources = { skillPaths: [...workspace.contextSources.skillPaths] };
|
|
25
30
|
const payload = {
|
|
26
|
-
agentsFiles:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
agentsFiles: {
|
|
32
|
+
sources: instructionSources,
|
|
33
|
+
files: agentsFiles
|
|
34
|
+
.map((file) => ({ path: resolve(file.path), content: file.content }))
|
|
35
|
+
.sort((left, right) => left.path.localeCompare(right.path)),
|
|
36
|
+
},
|
|
37
|
+
availableAgentsFiles: {
|
|
38
|
+
sources: instructionSources,
|
|
39
|
+
files: availableAgentsFiles
|
|
40
|
+
.map((file) => resolve(file.path))
|
|
41
|
+
.sort((left, right) => left.localeCompare(right)),
|
|
42
|
+
},
|
|
43
|
+
skills: {
|
|
44
|
+
sources: skillSources,
|
|
45
|
+
resources: workspace.skills
|
|
46
|
+
.map((skill) => ({
|
|
47
|
+
name: skill.name,
|
|
48
|
+
description: skill.description,
|
|
49
|
+
filePath: resolve(skill.filePath),
|
|
50
|
+
}))
|
|
51
|
+
.sort((left, right) => left.name.localeCompare(right.name) || left.filePath.localeCompare(right.filePath)),
|
|
52
|
+
},
|
|
53
|
+
skillDiagnostics: {
|
|
54
|
+
sources: skillSources,
|
|
55
|
+
diagnostics: workspace.skillDiagnostics,
|
|
56
|
+
},
|
|
40
57
|
capabilityGuides: workspace.capabilityGuides
|
|
41
58
|
.map((guide) => ({
|
|
42
59
|
name: guide.name,
|
|
@@ -4,10 +4,10 @@ import { dirname, join, relative, resolve, sep } from "node:path";
|
|
|
4
4
|
import { markCapabilityGuideActivated, resolveCapabilityGuideReadPath, } from "../mcp/server/core/capabilities.js";
|
|
5
5
|
import { readShellInstruction } from "../runtime/instructions/shell-instructions.js";
|
|
6
6
|
import { assertAllowedPath, isPathInsideRoot, resolveAllowedPath, } from "../mcp/filesystem/roots.js";
|
|
7
|
-
import { loadWorkspaceSkills, markSkillActivated, resolveSkillReadPath, } from "./resources/skills.js";
|
|
7
|
+
import { loadWorkspaceSkills, markSkillActivated, redactSkillDiagnosticMessage, resolveSkillReadPath, } from "./resources/skills.js";
|
|
8
|
+
import { defaultWorkspaceContextSources, resolveWorkspaceContextSources, } from "./resources/context-sources.js";
|
|
8
9
|
import { WorkspaceResourceMonitor, } from "./resources/resource-monitor.js";
|
|
9
10
|
const INITIAL_INSTRUCTION_DISCOVERY_DEPTH = 1;
|
|
10
|
-
const CONTEXT_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]);
|
|
11
11
|
const SKIPPED_CONTEXT_DIRS = new Set([
|
|
12
12
|
".git",
|
|
13
13
|
".hg",
|
|
@@ -28,6 +28,7 @@ const SKIPPED_CONTEXT_DIRS = new Set([
|
|
|
28
28
|
export class WorkspaceContextService {
|
|
29
29
|
config;
|
|
30
30
|
resourceMonitor = new WorkspaceResourceMonitor();
|
|
31
|
+
contextSourceRefreshes = new Map();
|
|
31
32
|
constructor(config) {
|
|
32
33
|
this.config = config;
|
|
33
34
|
}
|
|
@@ -114,7 +115,7 @@ export class WorkspaceContextService {
|
|
|
114
115
|
// system/shell instructions are intentionally exempt because they are
|
|
115
116
|
// trusted inputs explicitly advertised by ForgeRelay.
|
|
116
117
|
const trustedExternalInstructionPaths = new Set([
|
|
117
|
-
resolve(
|
|
118
|
+
resolve(workspace.contextSources.systemInstructionsPath),
|
|
118
119
|
...(this.config.shellInstructionPath ? [resolve(this.config.shellInstructionPath)] : []),
|
|
119
120
|
]);
|
|
120
121
|
if (!trustedExternalInstructionPaths.has(resolve(selectedPath))) {
|
|
@@ -136,8 +137,72 @@ export class WorkspaceContextService {
|
|
|
136
137
|
const directory = workingDirectory ? this.resolvePath(workspace, workingDirectory) : workspace.root;
|
|
137
138
|
return assertAllowedPath(directory, [workspace.root]);
|
|
138
139
|
}
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
defaultContextSources(root) {
|
|
141
|
+
return defaultWorkspaceContextSources(this.config, root);
|
|
142
|
+
}
|
|
143
|
+
async loadContextSourcesForWorkspace(project, root) {
|
|
144
|
+
const contextSources = await resolveWorkspaceContextSources(this.config, project, root);
|
|
145
|
+
return {
|
|
146
|
+
contextSources,
|
|
147
|
+
...this.loadSkillsForWorkspace(root, contextSources.skillPaths),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
async refreshContextSourcesForWorkspace(workspace) {
|
|
151
|
+
const existing = this.contextSourceRefreshes.get(workspace.id);
|
|
152
|
+
if (existing)
|
|
153
|
+
return existing;
|
|
154
|
+
const refresh = this.applyContextSourceRefresh(workspace);
|
|
155
|
+
this.contextSourceRefreshes.set(workspace.id, refresh);
|
|
156
|
+
try {
|
|
157
|
+
await refresh;
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
if (this.contextSourceRefreshes.get(workspace.id) === refresh)
|
|
161
|
+
this.contextSourceRefreshes.delete(workspace.id);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async applyContextSourceRefresh(workspace) {
|
|
165
|
+
if (!workspace.project)
|
|
166
|
+
return;
|
|
167
|
+
const contextSources = await resolveWorkspaceContextSources(this.config, workspace.project, workspace.root);
|
|
168
|
+
if (sameContextSources(workspace.contextSources, contextSources))
|
|
169
|
+
return;
|
|
170
|
+
const previousSources = workspace.contextSources;
|
|
171
|
+
const previousLoadedPaths = new Set([...workspace.loadedInstructionPaths].map((path) => resolve(path)));
|
|
172
|
+
const previousKnownPaths = new Set([...workspace.knownInstructionPathsByDir.values()].flat().map((path) => resolve(path)));
|
|
173
|
+
const previousAvailablePaths = new Set([...previousKnownPaths].filter((path) => !previousLoadedPaths.has(path)));
|
|
174
|
+
const previousSkills = workspace.skills.map((skill) => ({ ...skill }));
|
|
175
|
+
const previousSkillDiagnostics = workspace.skillDiagnostics.map((diagnostic) => ({ ...diagnostic }));
|
|
176
|
+
const nextSkills = this.loadSkillsForWorkspace(workspace.root, contextSources.skillPaths);
|
|
177
|
+
workspace.contextSources = contextSources;
|
|
178
|
+
workspace.skills = nextSkills.skills;
|
|
179
|
+
workspace.skillDiagnostics = nextSkills.skillDiagnostics;
|
|
180
|
+
const retainedSkillDirs = new Set(workspace.skills.map((skill) => resolve(skill.baseDir)));
|
|
181
|
+
for (const activatedDir of [...workspace.activatedSkillDirs]) {
|
|
182
|
+
if (!retainedSkillDirs.has(resolve(activatedDir)))
|
|
183
|
+
workspace.activatedSkillDirs.delete(activatedDir);
|
|
184
|
+
}
|
|
185
|
+
workspace.scannedInstructionDirs.clear();
|
|
186
|
+
workspace.knownInstructionPathsByDir.clear();
|
|
187
|
+
workspace.loadedInstructionRealPaths.clear();
|
|
188
|
+
workspace.loadedInstructionPaths.clear();
|
|
189
|
+
workspace.workspaceInstructions.length = 0;
|
|
190
|
+
const agentsFiles = await this.loadInitialAgentsFiles(workspace);
|
|
191
|
+
const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace, agentsFiles);
|
|
192
|
+
this.trackWorkspaceResources(workspace, agentsFiles, availableAgentsFiles);
|
|
193
|
+
this.resourceMonitor.announce(workspace.id, formatContextSourceRefresh({
|
|
194
|
+
workspace,
|
|
195
|
+
previousSources,
|
|
196
|
+
previousLoadedPaths,
|
|
197
|
+
previousAvailablePaths,
|
|
198
|
+
previousSkills,
|
|
199
|
+
previousSkillDiagnostics,
|
|
200
|
+
agentsFiles,
|
|
201
|
+
availableAgentsFiles,
|
|
202
|
+
}), ["agentsFiles", "availableAgentsFiles", "skills", "skillDiagnostics"]);
|
|
203
|
+
}
|
|
204
|
+
loadSkillsForWorkspace(root, skillPaths = this.config.skillPaths) {
|
|
205
|
+
const result = loadWorkspaceSkills(this.config, root, skillPaths);
|
|
141
206
|
return {
|
|
142
207
|
skills: result.skills,
|
|
143
208
|
skillDiagnostics: result.diagnostics,
|
|
@@ -154,7 +219,7 @@ export class WorkspaceContextService {
|
|
|
154
219
|
return assertAllowedPath(root, this.config.allowedRoots);
|
|
155
220
|
}
|
|
156
221
|
async loadInitialAgentsFiles(workspace) {
|
|
157
|
-
const systemInstructionsPath = resolve(
|
|
222
|
+
const systemInstructionsPath = resolve(workspace.contextSources.systemInstructionsPath);
|
|
158
223
|
const loadedFiles = [];
|
|
159
224
|
const systemInstructions = await readSystemInstructions(systemInstructionsPath);
|
|
160
225
|
const systemInstructionsRealPath = await tryRealpath(systemInstructionsPath);
|
|
@@ -164,6 +229,9 @@ export class WorkspaceContextService {
|
|
|
164
229
|
if (systemInstructionsRealPath)
|
|
165
230
|
workspace.loadedInstructionRealPaths.add(systemInstructionsRealPath);
|
|
166
231
|
}
|
|
232
|
+
else {
|
|
233
|
+
workspace.workspaceInstructions.push({ path: systemInstructionsPath, status: "unavailable" });
|
|
234
|
+
}
|
|
167
235
|
const shellInstructionPath = this.config.shellInstructionPath;
|
|
168
236
|
if (shellInstructionPath) {
|
|
169
237
|
const shellInstruction = await readShellInstruction(shellInstructionPath);
|
|
@@ -282,7 +350,7 @@ export class WorkspaceContextService {
|
|
|
282
350
|
const childDirectories = [];
|
|
283
351
|
for await (const entry of entries) {
|
|
284
352
|
const path = join(resolvedDirectory, entry.name);
|
|
285
|
-
if (entry.isFile() &&
|
|
353
|
+
if (entry.isFile() && workspace.contextSources.instructionNames.includes(entry.name)) {
|
|
286
354
|
instructionPaths.push(path);
|
|
287
355
|
continue;
|
|
288
356
|
}
|
|
@@ -341,6 +409,90 @@ export function formatAgentsPath(path, workspaceRoot) {
|
|
|
341
409
|
}
|
|
342
410
|
return relationship.split(sep).join("/");
|
|
343
411
|
}
|
|
412
|
+
function sameContextSources(left, right) {
|
|
413
|
+
return left.systemInstructionsPath === right.systemInstructionsPath &&
|
|
414
|
+
sameStringList(left.instructionNames, right.instructionNames) &&
|
|
415
|
+
sameStringList(left.skillPaths, right.skillPaths);
|
|
416
|
+
}
|
|
417
|
+
function sameStringList(left, right) {
|
|
418
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
419
|
+
}
|
|
420
|
+
function formatContextSourceRefresh(input) {
|
|
421
|
+
const { workspace, previousSources, previousLoadedPaths, previousAvailablePaths, previousSkills, previousSkillDiagnostics, agentsFiles, availableAgentsFiles, } = input;
|
|
422
|
+
const sections = ["Agent context source configuration changed; the effective Workspace context has been refreshed without reopening the Workspace."];
|
|
423
|
+
if (previousSources.systemInstructionsPath !== workspace.contextSources.systemInstructionsPath) {
|
|
424
|
+
sections.push([
|
|
425
|
+
"System instruction source changed:",
|
|
426
|
+
`- ${formatAgentsPath(previousSources.systemInstructionsPath, workspace.root)}`,
|
|
427
|
+
`+ ${formatAgentsPath(workspace.contextSources.systemInstructionsPath, workspace.root)}`,
|
|
428
|
+
].join("\n"));
|
|
429
|
+
}
|
|
430
|
+
if (!sameStringList(previousSources.instructionNames, workspace.contextSources.instructionNames)) {
|
|
431
|
+
sections.push([
|
|
432
|
+
"Project instruction filename selection changed:",
|
|
433
|
+
`- ${previousSources.instructionNames.join(", ") || "(none)"}`,
|
|
434
|
+
`+ ${workspace.contextSources.instructionNames.join(", ") || "(none)"}`,
|
|
435
|
+
].join("\n"));
|
|
436
|
+
}
|
|
437
|
+
if (!sameStringList(previousSources.skillPaths, workspace.contextSources.skillPaths)) {
|
|
438
|
+
sections.push([
|
|
439
|
+
"Skill source path selection changed:",
|
|
440
|
+
`- ${previousSources.skillPaths.join(", ") || "(none)"}`,
|
|
441
|
+
`+ ${workspace.contextSources.skillPaths.join(", ") || "(none)"}`,
|
|
442
|
+
].join("\n"));
|
|
443
|
+
}
|
|
444
|
+
const currentLoadedPaths = new Set(agentsFiles.map((file) => resolve(file.path)));
|
|
445
|
+
for (const path of [...previousLoadedPaths].filter((path) => !currentLoadedPaths.has(path)).sort()) {
|
|
446
|
+
sections.push(`Instruction source is no longer active: ${formatAgentsPath(path, workspace.root)}`);
|
|
447
|
+
}
|
|
448
|
+
for (const file of agentsFiles) {
|
|
449
|
+
if (previousLoadedPaths.has(resolve(file.path)))
|
|
450
|
+
continue;
|
|
451
|
+
sections.push([
|
|
452
|
+
`Instruction source loaded: ${formatAgentsPath(file.path, workspace.root)}`,
|
|
453
|
+
file.content,
|
|
454
|
+
].join("\n"));
|
|
455
|
+
}
|
|
456
|
+
const currentAvailablePaths = new Set(availableAgentsFiles.map((file) => resolve(file.path)));
|
|
457
|
+
for (const path of [...previousAvailablePaths].filter((path) => !currentAvailablePaths.has(path)).sort()) {
|
|
458
|
+
sections.push(`Lazy Workspace instruction is no longer advertised: ${formatAgentsPath(path, workspace.root)}`);
|
|
459
|
+
}
|
|
460
|
+
for (const file of availableAgentsFiles) {
|
|
461
|
+
if (previousAvailablePaths.has(resolve(file.path)))
|
|
462
|
+
continue;
|
|
463
|
+
sections.push(`Lazy Workspace instruction is now available: ${formatAgentsPath(file.path, workspace.root)}. Read it before working under that directory.`);
|
|
464
|
+
}
|
|
465
|
+
const previousSkillKeys = new Map(previousSkills.map((skill) => [
|
|
466
|
+
`${skill.name}\u0000${resolve(skill.filePath)}`,
|
|
467
|
+
skill,
|
|
468
|
+
]));
|
|
469
|
+
const currentSkillKeys = new Map(workspace.skills.map((skill) => [
|
|
470
|
+
`${skill.name}\u0000${resolve(skill.filePath)}`,
|
|
471
|
+
skill,
|
|
472
|
+
]));
|
|
473
|
+
for (const [key, skill] of previousSkillKeys) {
|
|
474
|
+
if (currentSkillKeys.has(key))
|
|
475
|
+
continue;
|
|
476
|
+
sections.push(`Skill metadata removed: skills://${encodeURIComponent(skill.name)}`);
|
|
477
|
+
}
|
|
478
|
+
for (const [key, skill] of currentSkillKeys) {
|
|
479
|
+
if (previousSkillKeys.has(key))
|
|
480
|
+
continue;
|
|
481
|
+
sections.push(`Skill metadata added: skills://${encodeURIComponent(skill.name)}\n+ description: ${skill.description}`);
|
|
482
|
+
}
|
|
483
|
+
if (JSON.stringify(previousSkillDiagnostics) !== JSON.stringify(workspace.skillDiagnostics)) {
|
|
484
|
+
if (workspace.skillDiagnostics.length === 0) {
|
|
485
|
+
sections.push("Skill diagnostics cleared.");
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
sections.push([
|
|
489
|
+
"Current Skill diagnostics:",
|
|
490
|
+
...workspace.skillDiagnostics.map((diagnostic) => `- ${diagnostic.type}: ${redactSkillDiagnosticMessage(diagnostic)}`),
|
|
491
|
+
].join("\n"));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return sections.join("\n\n");
|
|
495
|
+
}
|
|
344
496
|
async function readSystemInstructions(path) {
|
|
345
497
|
try {
|
|
346
498
|
return await readFile(path, "utf8");
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export async function runCliWithScriptedPseudoTerminal(args, env, steps, timeoutMs = 20_000) {
|
|
2
|
+
const nodePty = await import("node-pty");
|
|
3
|
+
const ptyEnv = Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== undefined));
|
|
4
|
+
const child = nodePty.spawn(process.execPath, ["--import", "tsx", "src/cli.ts", ...args], { cwd: process.cwd(), env: ptyEnv, name: "xterm-256color", cols: 80, rows: 24 });
|
|
5
|
+
let terminalOutput = "";
|
|
6
|
+
let stepIndex = 0;
|
|
7
|
+
const dataDisposable = child.onData((chunk) => {
|
|
8
|
+
terminalOutput += chunk;
|
|
9
|
+
const step = steps[stepIndex];
|
|
10
|
+
if (step && step.match.test(terminalOutput)) {
|
|
11
|
+
stepIndex += 1;
|
|
12
|
+
child.write(`${step.input}\r`);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
16
|
+
const status = await new Promise((resolve) => {
|
|
17
|
+
child.onExit(({ exitCode }) => resolve(exitCode));
|
|
18
|
+
});
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
dataDisposable.dispose();
|
|
21
|
+
return { status, output: terminalOutput };
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { expandHomePath } from "../../mcp/filesystem/roots.js";
|
|
3
|
+
import { DEFAULT_INSTRUCTION_NAMES, DEFAULT_SKILL_PATHS, DEFAULT_SYSTEM_INSTRUCTIONS_PATH, generalConfigDefinition, } from "../../runtime/config/definition/general-config.js";
|
|
4
|
+
import { resolveProjectGeneralConfig } from "../../runtime/config/resolution/project-sources.js";
|
|
5
|
+
import { assertConfigResolutionValid } from "../../runtime/config/resolution/resolver.js";
|
|
6
|
+
export function defaultWorkspaceContextSources(config, workspaceRoot) {
|
|
7
|
+
return {
|
|
8
|
+
systemInstructionsPath: resolveContextPath(config.systemInstructionsPath, workspaceRoot),
|
|
9
|
+
instructionNames: [...config.instructionNames],
|
|
10
|
+
skillPaths: [...config.skillPaths],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export async function resolveWorkspaceContextSources(config, project, workspaceRoot) {
|
|
14
|
+
const inputs = config.configRuntime.resolutionInputsFor(generalConfigDefinition.domain);
|
|
15
|
+
const resolution = await resolveProjectGeneralConfig(project, {
|
|
16
|
+
env: inputs.environment,
|
|
17
|
+
...(inputs.cli ? { cli: inputs.cli } : {}),
|
|
18
|
+
});
|
|
19
|
+
assertConfigResolutionValid(resolution);
|
|
20
|
+
const values = resolution.values;
|
|
21
|
+
return {
|
|
22
|
+
systemInstructionsPath: resolveContextPath(values.systemInstructionsPath ?? DEFAULT_SYSTEM_INSTRUCTIONS_PATH, workspaceRoot),
|
|
23
|
+
instructionNames: [...(values.instructionNames ?? DEFAULT_INSTRUCTION_NAMES)],
|
|
24
|
+
skillPaths: [...(values.skillPaths ?? DEFAULT_SKILL_PATHS)],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function resolveContextPath(value, workspaceRoot) {
|
|
28
|
+
return resolve(workspaceRoot, expandHomePath(value));
|
|
29
|
+
}
|