@akira-tl/forgerelay 1.2.4 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +19 -11
  3. package/dist/cli/config/domains/context-cli.js +86 -0
  4. package/dist/cli/config/domains/domain-cli.js +468 -0
  5. package/dist/cli/config/general.js +174 -0
  6. package/dist/cli/config/inspect.js +29 -26
  7. package/dist/cli/config/migrate.js +11 -24
  8. package/dist/cli/config/scope.js +35 -0
  9. package/dist/cli/connect/relay.js +284 -0
  10. package/dist/cli/core/command-tree.js +68 -0
  11. package/dist/cli/core/serve-options.js +71 -0
  12. package/dist/cli/init/setup-config.js +26 -2
  13. package/dist/cli/init.js +37 -8
  14. package/dist/cli/maintenance-prune.js +1 -1
  15. package/dist/cli/maintenance.js +6 -6
  16. package/dist/cli/mcp/external-mcp.js +29 -17
  17. package/dist/cli/mcp/status.js +2 -2
  18. package/dist/cli/setup-support.js +3 -2
  19. package/dist/cli/system/status.js +35 -0
  20. package/dist/cli.js +132 -272
  21. package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +2 -2
  22. package/dist/mcp/server/core/schemas.js +2 -10
  23. package/dist/mcp/server/operations/runtime/operation-runtime.js +11 -7
  24. package/dist/runtime/config/config.js +30 -29
  25. package/dist/runtime/config/definition/general-config.js +22 -6
  26. package/dist/runtime/config/external-mcp-config.js +4 -3
  27. package/dist/runtime/config/resolution/resolver.js +7 -4
  28. package/dist/runtime/config/user-config.js +9 -6
  29. package/dist/runtime/config/validation/paths.js +12 -0
  30. package/dist/runtime/config/validation/ports.js +9 -0
  31. package/dist/subagents/profiles.js +37 -0
  32. package/dist/workspaces/bootstrap.js +31 -14
  33. package/dist/workspaces/context.js +159 -7
  34. package/dist/workspaces/relay/auth/cli-test-support.js +22 -0
  35. package/dist/workspaces/resources/context-sources.js +29 -0
  36. package/dist/workspaces/resources/resource-monitor.js +29 -6
  37. package/dist/workspaces/resources/skills.js +15 -10
  38. package/dist/workspaces/sessions.js +4 -2
  39. package/dist/workspaces/state/project-context.js +34 -8
  40. package/dist/workspaces.js +5 -2
  41. package/docs/chatgpt-coding-workflow.md +23 -20
  42. package/docs/configuration.md +40 -27
  43. package/docs/gotchas.md +8 -6
  44. package/docs/roadmap.md +1 -1
  45. package/package.json +2 -2
  46. package/schemas/v1/config.project-local.schema.json +74 -0
  47. package/schemas/v1/config.project.schema.json +74 -0
  48. package/schemas/v1/config.user.schema.json +59 -4
  49. package/scripts/ci/config-v2-product-acceptance.mjs +17 -12
  50. package/scripts/debug/runtime.mjs +19 -3
  51. package/scripts/debug/runtime.test.mjs +3 -0
  52. 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 { forgerelaySkillsDir, generateInstanceId, loadForgeRelayFiles, } from "./user-config.js";
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
- if (Array.isArray(value)) {
19
- const roots = value.map((entry) => entry.trim()).filter(Boolean);
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
- export function loadConfig(env = process.env, options = {}) {
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: parsePathList(productEnv(env, "SKILL_PATHS")),
309
- configSkillsDir: forgerelaySkillsDir(env),
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())),
@@ -1,7 +1,12 @@
1
1
  import * as z from "zod/v4";
2
2
  import { defineConfigDomain } from "./definition.js";
3
+ import { LISTEN_PORT_MIN, PORT_MAX } from "../validation/ports.js";
3
4
  const USER_RUNTIME_SCOPES = ["runtime", "user", "built-in"];
5
+ const CONTEXT_SOURCE_SCOPES = ["runtime", "project-local", "project", "user", "built-in"];
4
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"];
5
10
  const USER_ONLY_SCOPES = ["user"];
6
11
  const LEGACY_REMOVAL_VERSION = "1.4.0";
7
12
  const commandShellSchema = z.object({
@@ -13,6 +18,7 @@ const retentionSchema = z.object({
13
18
  historyDays: z.number().int().min(1).max(36_500).optional(),
14
19
  orphanedAdministrativeState: z.boolean().optional(),
15
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.");
16
22
  export const generalConfigDefinition = defineConfigDomain({
17
23
  domain: "config",
18
24
  title: "ForgeRelay general configuration",
@@ -24,11 +30,11 @@ export const generalConfigDefinition = defineConfigDomain({
24
30
  reload: "restart-required",
25
31
  runtimeOverride: runtimeEnv("HOST", (env) => env.HOST),
26
32
  }),
27
- port: field(z.number().int().min(1).max(65535), "Local listening port.", {
33
+ port: field(z.number().int().min(LISTEN_PORT_MIN).max(PORT_MAX), `Local listening port (${LISTEN_PORT_MIN}-${PORT_MAX}).`, {
28
34
  scopes: USER_RUNTIME_SCOPES,
29
35
  builtIn: literal(7676),
30
36
  reload: "restart-required",
31
- runtimeOverride: runtimeEnv("PORT", (env) => readIntegerEnv(env, "PORT", 1, 65535)),
37
+ runtimeOverride: runtimeEnv("PORT", (env) => readIntegerEnv(env, "PORT", LISTEN_PORT_MIN, PORT_MAX)),
32
38
  }),
33
39
  allowedRoots: field(z.array(z.string()), "Filesystem roots that Workspaces may open.", {
34
40
  scopes: USER_RUNTIME_SCOPES,
@@ -112,14 +118,24 @@ export const generalConfigDefinition = defineConfigDomain({
112
118
  reload: "restart-required",
113
119
  runtimeOverride: runtimeEnv("FORGERELAY_AGENT_DIR", (env) => env.FORGERELAY_AGENT_DIR),
114
120
  }),
115
- systemInstructionsPath: field(z.string().min(1), "Global Agent instruction file consumed by ForgeRelay.", {
116
- scopes: USER_RUNTIME_SCOPES,
117
- builtIn: computed("~/.agents/AGENTS.md"),
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),
118
124
  runtimeOverride: runtimeEnv("FORGERELAY_SYSTEM_INSTRUCTIONS_PATH", (env) => readNonEmptyPathEnv(env, "FORGERELAY_SYSTEM_INSTRUCTIONS_PATH")),
119
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
+ }),
120
136
  commandShell: field(commandShellSchema, "Recorded command-shell preference for ForgeRelay command and Hook execution.", {
121
137
  scopes: USER_SCOPES,
122
- builtIn: computed("Detected launcher shell with the recorded compatibility fallback used when needed."),
138
+ builtIn: computed("Platform compatibility default unless an explicit shell preference is recorded."),
123
139
  reload: "restart-required",
124
140
  }),
125
141
  shellInstructions: field(z.boolean(), "Enable ForgeRelay-owned runtime shell instructions.", {
@@ -1,4 +1,5 @@
1
1
  import * as z from "zod/v4";
2
+ import { isIntegerPort, OAUTH_CALLBACK_PORT_MIN, PORT_MAX } from "./validation/ports.js";
2
3
  const MCP_SERVER_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
3
4
  const MAX_MCP_SERVERS = 32;
4
5
  const nonEmptyStringSchema = z.string().trim().min(1);
@@ -13,7 +14,7 @@ const clientMetadataUrlSchema = z.string().url().refine((value) => {
13
14
  }, "clientMetadataUrl must use https and contain a non-root path.");
14
15
  const oauthSourceSchema = z.object({
15
16
  clientMetadataUrl: clientMetadataUrlSchema,
16
- callbackPort: z.number().int().min(1024).max(65535),
17
+ callbackPort: z.number().int().min(OAUTH_CALLBACK_PORT_MIN).max(PORT_MAX),
17
18
  }).strict();
18
19
  const stdioSourceSchema = z.object({
19
20
  transport: z.literal("stdio"),
@@ -136,8 +137,8 @@ function parseOAuthClientConfig(value, label) {
136
137
  throw new Error(`${label}.clientMetadataUrl must use https and contain a non-root path.`);
137
138
  }
138
139
  const callbackPort = value.callbackPort;
139
- if (!Number.isInteger(callbackPort) || Number(callbackPort) < 1024 || Number(callbackPort) > 65535) {
140
- throw new Error(`${label}.callbackPort must be an integer from 1024 to 65535.`);
140
+ if (!isIntegerPort(callbackPort, OAUTH_CALLBACK_PORT_MIN, PORT_MAX)) {
141
+ throw new Error(`${label}.callbackPort must be an integer from ${OAUTH_CALLBACK_PORT_MIN} to ${PORT_MAX}.`);
141
142
  }
142
143
  return {
143
144
  clientMetadataUrl: parsed.toString(),
@@ -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: `${logicalPath} is deprecated since ForgeRelay ${field.deprecation.since}` +
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} is deprecated since ForgeRelay ${deprecation.since}` +
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) {
@@ -5,6 +5,8 @@ import { homedir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { withFileLock } from "../state/lock/file-lock.js";
7
7
  import { expandHomePath } from "../../mcp/filesystem/roots.js";
8
+ import { parseConfigSource } from "./definition/definition.js";
9
+ import { generalConfigDefinition } from "./definition/general-config.js";
8
10
  export function forgerelayConfigDir(env = process.env) {
9
11
  const explicit = env.FORGERELAY_CONFIG_DIR;
10
12
  if (explicit)
@@ -23,9 +25,6 @@ export function forgerelayHooksPath(env = process.env) {
23
25
  export function forgerelayHooksDir(env = process.env) {
24
26
  return join(forgerelayConfigDir(env), "hooks");
25
27
  }
26
- export function forgerelaySkillsDir(env = process.env) {
27
- return join(forgerelayConfigDir(env), "skills");
28
- }
29
28
  export function loadForgeRelayFiles(env = process.env, options = {}) {
30
29
  const dir = forgerelayConfigDir(env);
31
30
  const configPath = join(dir, "config.json");
@@ -49,8 +48,9 @@ export function loadForgeRelayFiles(env = process.env, options = {}) {
49
48
  }
50
49
  export function writeForgeRelayConfig(config, env = process.env) {
51
50
  const filePath = forgerelayConfigPath(env);
51
+ const validated = parseConfigSource(generalConfigDefinition, "user", config);
52
52
  mkdirSync(forgerelayConfigDir(env), { recursive: true });
53
- writeJsonFile(filePath, config, 0o600);
53
+ writeConfigJsonFile(filePath, validated, 0o600);
54
54
  return filePath;
55
55
  }
56
56
  export async function writeForgeRelayAuth(auth, env = process.env) {
@@ -185,10 +185,13 @@ async function replaceAuthFile(tempPath, filePath) {
185
185
  function delay(ms) {
186
186
  return new Promise((resolve) => setTimeout(resolve, ms));
187
187
  }
188
- function writeJsonFile(filePath, value, mode) {
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) {
189
192
  const tempPath = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
190
193
  try {
191
- writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n", { mode });
194
+ writeFileSync(tempPath, value, { mode });
192
195
  renameSync(tempPath, filePath);
193
196
  }
194
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
+ }
@@ -0,0 +1,9 @@
1
+ export const LISTEN_PORT_MIN = 1;
2
+ export const PORT_MAX = 65_535;
3
+ export const OAUTH_CALLBACK_PORT_MIN = 1_024;
4
+ export function isIntegerPort(value, minimum = LISTEN_PORT_MIN, maximum = PORT_MAX) {
5
+ return typeof value === "number"
6
+ && Number.isInteger(value)
7
+ && value >= minimum
8
+ && value <= maximum;
9
+ }
@@ -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: agentsFiles
27
- .map((file) => ({ path: resolve(file.path), content: file.content }))
28
- .sort((left, right) => left.path.localeCompare(right.path)),
29
- availableAgentsFiles: availableAgentsFiles
30
- .map((file) => resolve(file.path))
31
- .sort((left, right) => left.localeCompare(right)),
32
- skills: workspace.skills
33
- .map((skill) => ({
34
- name: skill.name,
35
- description: skill.description,
36
- filePath: resolve(skill.filePath),
37
- }))
38
- .sort((left, right) => left.name.localeCompare(right.name) || left.filePath.localeCompare(right.filePath)),
39
- skillDiagnostics: workspace.skillDiagnostics,
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(this.config.systemInstructionsPath),
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
- loadSkillsForWorkspace(root) {
140
- const result = loadWorkspaceSkills(this.config, root);
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(this.config.systemInstructionsPath);
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() && CONTEXT_FILE_NAMES.has(entry.name)) {
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
+ }