@akira-tl/forgerelay 1.3.2 → 1.3.4

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 CHANGED
@@ -4,6 +4,19 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.3.4] - 2026-10-29
8
+
9
+ ### Added
10
+
11
+ - 新增只读 `workspace.conversation-health` Capability:仅使用 ForgeRelay 可观察的 Host Turn、持久 Workspace checkpoint、Task 与 Git 状态给出 `healthy`、`checkpoint-recommended` 或 `rotation-recommended` 建议,不推测 Host token、上下文窗口利用率或模型内部状态。
12
+ - 默认策略在当前对话作用域与 Workspace 累积 12 个 Host Turn 时建议建立 checkpoint,在 24 个 Host Turn 时建议轮换到新对话;checkpoint 创建后的 8 个 Host Turn 内视为近期恢复边界。交接信息保持有界,只包含未完成 Task 头部、最新 checkpoint 元数据和 Git 摘要;Relay 与 Composite 显式 member 路径保持同样语义。
13
+
14
+ ## [1.3.3] - 2026-10-15
15
+
16
+ ### Fixed
17
+
18
+ - `open_workspace` 现在明确区分 Host 顶层 Core tool、`capabilityCatalog` 与 `capabilityFingerprint`:`minimal` / `full` 的核心工具仍由 Host 当前 `tools/list` snapshot 决定,不再允许仅因 fingerprint/catalog 未列出 `write`、`edit` 或 `bash` 就把 ForgeRelay 判断为只读;模型可见标签也改为明确的 Capability catalog 与 semantic feature fingerprint 表述。
19
+
7
20
  ## [1.3.2] - 2026-10-08
8
21
 
9
22
  ### Security
@@ -0,0 +1,36 @@
1
+ # Workspace Conversation Health
2
+
3
+ `workspace.conversation-health` is a low-frequency, read-only advisory capability for deciding when a long-lived Host conversation should checkpoint or rotate while durable Workspace state remains canonical.
4
+
5
+ ## Policy
6
+
7
+ The current deterministic policy uses only ForgeRelay-observable state:
8
+
9
+ - recommend a checkpoint at 12 Host Turns when no recent persistent checkpoint exists;
10
+ - recommend conversation rotation at 24 Host Turns;
11
+ - treat a checkpoint as recent for 8 Host Turns after its creation.
12
+
13
+ These thresholds are policy constants, not estimates of model token usage, context-window utilization, memory pressure, or reasoning state.
14
+
15
+ The result exposes the policy thresholds, Host Turn counts, bounded active Task summaries, the latest persistent checkpoint when available, and bounded Git state for Git-backed Workspaces.
16
+
17
+ ## Recommended actions
18
+
19
+ - `continue` — the current conversation is still within the advisory threshold.
20
+ - `checkpoint` — create a named `workspace.checkpoint` before continuing.
21
+ - `checkpoint-and-rotate` — create a fresh persistent checkpoint, then move to a fresh Host conversation.
22
+ - `rotate` — a recent checkpoint already exists; move to a fresh Host conversation and reopen the same Workspace.
23
+
24
+ The capability never creates a checkpoint, closes a Host conversation, stores the Host transcript, or forces rotation.
25
+
26
+ ## Durable handoff
27
+
28
+ A fresh Host conversation should call `open_workspace` for the same canonical Workspace and resume from durable state. Workspace Tasks, persistent checkpoints, Git state, and Project status artifacts remain the source of truth; the previous Host transcript is not required for recovery.
29
+
30
+ Task handoff metadata is summary-only and excludes Task content. Checkpoint metadata is bounded and excludes patches or file contents.
31
+
32
+ ## Relay and Composite Workspaces
33
+
34
+ For a relayed Workspace, the capability executes on the remote Execution ForgeRelay so Host Turn, Task, checkpoint, and Git observations come from the Workspace owner.
35
+
36
+ A Composite Workspace has no single filesystem conversation-health state. Pass an explicit `member`; the capability evaluates that member's Execution Workspace.
@@ -34,6 +34,16 @@ export class HostTurnStore {
34
34
  limit 1`).get(conversationScopeId, workspaceId);
35
35
  return row ? rowToTurn(row) : undefined;
36
36
  }
37
+ countForConversationWorkspace(conversationScopeId, workspaceId, after) {
38
+ const row = after
39
+ ? this.database.sqlite.prepare(`select count(*) as count
40
+ from activity_host_turns
41
+ where conversation_scope_id = ? and workspace_id = ? and created_at > ?`).get(conversationScopeId, workspaceId, after)
42
+ : this.database.sqlite.prepare(`select count(*) as count
43
+ from activity_host_turns
44
+ where conversation_scope_id = ? and workspace_id = ?`).get(conversationScopeId, workspaceId);
45
+ return Number(row?.count ?? 0);
46
+ }
37
47
  deleteWorkspace(workspaceId) {
38
48
  this.database.sqlite.prepare("delete from activity_host_turns where workspace_id = ?").run(workspaceId);
39
49
  }
@@ -24,6 +24,12 @@ export class ActivityQueryService {
24
24
  currentTurnId(conversationScopeId, workspaceId) {
25
25
  return this.turns.current(conversationScopeId, workspaceId)?.turnId;
26
26
  }
27
+ countTurns(conversationScopeId, workspaceId, after) {
28
+ return this.turns.countForConversationWorkspace(conversationScopeId, workspaceId, after);
29
+ }
30
+ turnWorkspaceId(turnId) {
31
+ return this.turns.get(turnId)?.workspaceId;
32
+ }
27
33
  state(turnId, knownRevision) {
28
34
  this.requireTurn(turnId);
29
35
  const aggregate = this.audit.turnState(turnId);
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { access } from "node:fs/promises";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
@@ -14,11 +15,16 @@ export function createActivityPanelApp(config, fallbackRevision) {
14
15
  const identity = currentIdentity(config, fallbackRevision);
15
16
  const domain = new URL(config.publicBaseUrl).origin;
16
17
  const csp = activityPanelCsp(config);
18
+ const openAiWidgetCsp = openAiCompatibilityCsp(csp);
17
19
  return {
18
20
  uri: identity.uri,
19
21
  resourceMetadata: {
20
22
  description: "ForgeRelay unified Workspace and Activity UI for one Host Turn.",
21
- _meta: { ui: { domain, csp } },
23
+ _meta: {
24
+ ui: { domain, csp },
25
+ "openai/widgetDomain": domain,
26
+ "openai/widgetCSP": openAiWidgetCsp,
27
+ },
22
28
  },
23
29
  toolMeta: config.widgets === "off"
24
30
  ? { _meta: {} }
@@ -28,6 +34,8 @@ export function createActivityPanelApp(config, fallbackRevision) {
28
34
  resourceUri: identity.uri,
29
35
  visibility: ["model", "app"],
30
36
  },
37
+ "openai/outputTemplate": identity.uri,
38
+ "openai/widgetAccessible": true,
31
39
  },
32
40
  },
33
41
  readResource: (requestedUri, transportSessionId) => readActivityPanelAppResource(config, fallbackRevision, requestedUri, transportSessionId),
@@ -70,11 +78,18 @@ function activityPanelCsp(config) {
70
78
  connectDomains: publicOrigins,
71
79
  };
72
80
  }
81
+ function openAiCompatibilityCsp(csp) {
82
+ return {
83
+ resource_domains: csp.resourceDomains,
84
+ connect_domains: csp.connectDomains,
85
+ };
86
+ }
73
87
  async function readActivityPanelAppResource(config, fallbackRevision, requestedUri, transportSessionId) {
74
88
  const identity = currentIdentity(config, fallbackRevision);
75
89
  const entry = readActivityPanelAppManifestEntry(uiManifestUrl());
76
90
  const domain = new URL(config.publicBaseUrl).origin;
77
91
  const csp = activityPanelCsp(config);
92
+ const openAiWidgetCsp = openAiCompatibilityCsp(csp);
78
93
  try {
79
94
  await assertMcpAppAssets(entry);
80
95
  const result = {
@@ -84,6 +99,10 @@ async function readActivityPanelAppResource(config, fallbackRevision, requestedU
84
99
  text: activityPanelAppHtml(config, entry),
85
100
  _meta: {
86
101
  ui: { domain, csp },
102
+ // ChatGPT compatibility aliases remain useful for older/mobile hosts
103
+ // that have not fully converged on the MCP Apps standard metadata.
104
+ "openai/widgetDomain": domain,
105
+ "openai/widgetCSP": openAiWidgetCsp,
87
106
  // MCP Apps defines these values under `_meta.ui`. Inspector 2.3.0
88
107
  // still reads them directly from the content-item metadata, so mirror
89
108
  // them until that installed-host compatibility gap is retired.
@@ -112,25 +131,34 @@ async function readActivityPanelAppResource(config, fallbackRevision, requestedU
112
131
  function activityPanelAppHtml(config, entry) {
113
132
  const baseUrl = `${config.publicBaseUrl.replace(/\/+$/, "")}/mcp-app-assets`;
114
133
  const assetUrl = (assetPath) => `${baseUrl}/${assetPath.replace(/^\/+/, "")}`;
115
- const stylesheets = (entry.css ?? [])
116
- .map((stylesheet) => ` <link rel="stylesheet" crossorigin href="${assetUrl(stylesheet)}" />`)
117
- .join("\n");
134
+ const entryDirectory = entry.file.includes("/")
135
+ ? entry.file.slice(0, entry.file.lastIndexOf("/") + 1)
136
+ : "";
137
+ const styles = (entry.css ?? [])
138
+ .map((stylesheet) => readUiAssetText(stylesheet).replace(/<\/style/gi, "<\\/style"))
139
+ .join("\\n");
140
+ const script = readUiAssetText(entry.file)
141
+ .replace(/import\((["'`])\.\/([^"'`]+)\1\)/g, (_match, quote, relativePath) => `import(${quote}${assetUrl(`${entryDirectory}${relativePath}`)}${quote})`)
142
+ .replace(/<\/script/gi, "<\\/script");
118
143
  return `<!doctype html>
119
144
  <html lang="en">
120
145
  <head>
121
146
  <meta charset="UTF-8" />
122
147
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
123
148
  <title>ForgeRelay Activity Panel</title>
124
- <script type="module" crossorigin src="${assetUrl(entry.file)}"></script>
125
- ${stylesheets}
149
+ <style>${styles}</style>
126
150
  </head>
127
151
  <body>
128
152
  <main id="app" class="shell">
129
153
  <section class="empty">Waiting for Activity Panel state.</section>
130
154
  </main>
155
+ <script type="module">${script}</script>
131
156
  </body>
132
157
  </html>`;
133
158
  }
159
+ function readUiAssetText(assetPath) {
160
+ return readFileSync(new URL(`../../../dist/ui/${assetPath}`, import.meta.url), "utf8");
161
+ }
134
162
  async function assertMcpAppAssets(entry) {
135
163
  const candidates = [entry.file, ...(entry.css ?? [])].map((assetPath) => new URL(`../../../dist/ui/${assetPath}`, import.meta.url));
136
164
  for (const candidate of candidates)
@@ -4,7 +4,7 @@ export const ACTIVITY_PANEL_APP_MANIFEST_ENTRY = "activity-panel-app.html";
4
4
  // Host caches are keyed by the ui:// resource URI, so the revision must cover
5
5
  // both bundle assets and the HTML/resource contract that serves them. Bump this
6
6
  // whenever either side changes so a Host never reuses stale UI bytes.
7
- export const MCP_APP_RESOURCE_TEMPLATE_REVISION = "5";
7
+ export const MCP_APP_RESOURCE_TEMPLATE_REVISION = "7";
8
8
  export function activityPanelAppUriForRevision(revision) {
9
9
  return `ui://forgerelay/activity-panel-app-${encodeURIComponent(revision)}.html`;
10
10
  }
@@ -0,0 +1,34 @@
1
+ export function createWorkspaceCheckpointCapabilityRuntime(store) {
2
+ return {
3
+ available: true,
4
+ run: async (input, context) => {
5
+ const root = context.workspaceRoot;
6
+ if (!root)
7
+ throw new Error("Workspace checkpoint requires a filesystem-backed Workspace.");
8
+ switch (input.operation) {
9
+ case "create":
10
+ return {
11
+ value: {
12
+ workspaceId: context.workspaceId,
13
+ checkpoint: await store.create(context.workspaceId, root, input.name),
14
+ ignoredFilesIncluded: false,
15
+ },
16
+ };
17
+ case "list":
18
+ return { value: await store.list(context.workspaceId, root, input) };
19
+ case "inspect":
20
+ return { value: await store.inspect(context.workspaceId, root, input.checkpointId) };
21
+ case "restore.preflight":
22
+ return {
23
+ value: await store.preflightRestore(context.workspaceId, root, input.checkpointId),
24
+ };
25
+ case "restore":
26
+ return {
27
+ value: await store.restore(context.workspaceId, root, input.checkpointId, input.expectedCurrentSnapshot),
28
+ };
29
+ case "delete":
30
+ return { value: await store.delete(context.workspaceId, root, input.checkpointId) };
31
+ }
32
+ },
33
+ };
34
+ }
@@ -27,3 +27,20 @@ export const workspaceCheckpointInputSchema = z.discriminatedUnion("operation",
27
27
  checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
28
28
  }).strict(),
29
29
  ]);
30
+ export function workspaceCheckpointCapabilityDefinitions(dependency, availability) {
31
+ if (!dependency)
32
+ return [];
33
+ return [{
34
+ name: "workspace.checkpoint",
35
+ description: [
36
+ "Create, list, inspect, safely restore, or delete immutable Git-backed checkpoints",
37
+ "owned by the current persistent Workspace.",
38
+ ].join(" "),
39
+ guideName: "workspace-checkpoints",
40
+ readGuideBeforeFirstUse: true,
41
+ batchPolicy: "unsupported",
42
+ inputSchema: workspaceCheckpointInputSchema,
43
+ availability: (context) => availability(context, dependency.available, dependency.unavailableReason),
44
+ run: async (input, context, options) => dependency.run(input, context, options),
45
+ }];
46
+ }
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ export const workspaceConversationHealthInputSchema = z.object({}).strict();
3
+ export function workspaceConversationHealthCapabilityDefinitions(dependency, availability) {
4
+ if (!dependency)
5
+ return [];
6
+ return [{
7
+ name: "workspace.conversation-health",
8
+ description: [
9
+ "Inspect deterministic conversation-aging signals and bounded durable handoff metadata",
10
+ "for the current Workspace.",
11
+ ].join(" "),
12
+ guideName: "workspace-conversation-health",
13
+ readGuideBeforeFirstUse: true,
14
+ batchPolicy: "unsupported",
15
+ inputSchema: workspaceConversationHealthInputSchema,
16
+ availability: (context) => availability(context, dependency.available, dependency.unavailableReason),
17
+ run: async (_input, context, options) => dependency.run(context, options),
18
+ }];
19
+ }
@@ -58,6 +58,12 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
58
58
  description: "Immutable persistent Git-backed checkpoints owned by the current Workspace.",
59
59
  whenToRead: "Read before creating, listing, inspecting, or deleting Workspace checkpoints.",
60
60
  },
61
+ {
62
+ name: "workspace-conversation-health",
63
+ directory: "workspace/workspace-conversation-health",
64
+ description: "Deterministic Host conversation aging guidance and bounded durable handoff metadata.",
65
+ whenToRead: "Read before using workspace.conversation-health for checkpoint or conversation-rotation guidance.",
66
+ },
61
67
  {
62
68
  name: "batch-execution",
63
69
  description: "One-call execution of multiple independent ForgeRelay core operations.",
@@ -109,6 +115,7 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
109
115
  "workspace.tasks",
110
116
  "workspace.recovery",
111
117
  "workspace.checkpoint",
118
+ "workspace.conversation-health",
112
119
  ];
113
120
  if (config.toolMode !== "codex") {
114
121
  capabilities.push("batch.execute");
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_CODE_INTELLIGENCE_RESULT_LIMIT, } from "../../../lsp/code-intelligence-types.js";
3
3
  import { batchExecuteInputSchema, } from "../../operations/batch/types.js";
4
- import { workspaceCheckpointInputSchema, } from "./capabilities/workspace-checkpoint.js";
4
+ import { workspaceCheckpointCapabilityDefinitions, } from "./capabilities/workspace-checkpoint.js";
5
+ import { workspaceConversationHealthCapabilityDefinitions, } from "./capabilities/workspace-conversation-health.js";
5
6
  import { externalMcpCapabilityDefinitions, } from "./capabilities/external-mcp.js";
6
7
  export class CapabilityError extends Error {
7
8
  code;
@@ -306,18 +307,8 @@ export function createCapabilityRegistry(dependencies) {
306
307
  run: async (input, context, options) => dependencies.workspaceRecovery.run(input, context, options),
307
308
  }]
308
309
  : []),
309
- ...(dependencies.workspaceCheckpoint
310
- ? [{
311
- name: "workspace.checkpoint",
312
- description: "Create, list, inspect, safely restore, or delete immutable Git-backed checkpoints owned by the current persistent Workspace.",
313
- guideName: "workspace-checkpoints",
314
- readGuideBeforeFirstUse: true,
315
- batchPolicy: "unsupported",
316
- inputSchema: workspaceCheckpointInputSchema,
317
- availability: (context) => filesystemWorkspaceAvailability(context, dependencies.workspaceCheckpoint?.available ?? false, dependencies.workspaceCheckpoint?.unavailableReason),
318
- run: async (input, context, options) => dependencies.workspaceCheckpoint.run(input, context, options),
319
- }]
320
- : []),
310
+ ...workspaceCheckpointCapabilityDefinitions(dependencies.workspaceCheckpoint, filesystemWorkspaceAvailability),
311
+ ...workspaceConversationHealthCapabilityDefinitions(dependencies.workspaceConversationHealth, filesystemWorkspaceAvailability),
321
312
  ...(dependencies.workspaceTasks
322
313
  ? [{
323
314
  name: "workspace.tasks",
@@ -510,6 +510,8 @@ export function createOperationRuntime(options) {
510
510
  sessionId: context.sessionId,
511
511
  batch: context.batch,
512
512
  activityId: activityContext.activityId,
513
+ turnId: activityContext.turnId,
514
+ conversationScopeId: context.conversationScopeId,
513
515
  });
514
516
  changedPaths = execution.changedPaths ?? [];
515
517
  const transientContent = execution.content;
@@ -0,0 +1,30 @@
1
+ import { rebaseConversationHealthAge, } from "../../../../workspaces/state/conversation-health.js";
2
+ import { textBlock } from "../../core/tool-support.js";
3
+ export function rebaseRemoteCompositeConversationHealth(response, compositeWorkspaceId, conversationScopeId, activityQueries) {
4
+ if (response.isError === true)
5
+ return response;
6
+ const structured = response.structuredContent;
7
+ if (!structured || typeof structured !== "object")
8
+ return response;
9
+ const result = structured.result;
10
+ if (!result || typeof result !== "object")
11
+ return response;
12
+ const health = result;
13
+ const latestCheckpoint = health.latestCheckpoint;
14
+ const hostTurnCount = activityQueries.countTurns(conversationScopeId, compositeWorkspaceId);
15
+ const turnsSinceLatestCheckpoint = latestCheckpoint
16
+ ? activityQueries.countTurns(conversationScopeId, compositeWorkspaceId, latestCheckpoint.createdAt)
17
+ : hostTurnCount;
18
+ const rebased = rebaseConversationHealthAge(health, {
19
+ hostTurnCount,
20
+ turnsSinceLatestCheckpoint,
21
+ });
22
+ return {
23
+ ...response,
24
+ content: [textBlock(`Capability workspace.conversation-health completed.\n${JSON.stringify(rebased, null, 2)}`)],
25
+ structuredContent: {
26
+ ...structured,
27
+ result: rebased,
28
+ },
29
+ };
30
+ }
@@ -12,7 +12,7 @@ import { logToolCall, workspaceLogContext } from "../../core/tool-support.js";
12
12
  import { buildExecutionShellContext } from "../../../server-instructions.js";
13
13
  import { workspaceColorFor, workspaceIdentityName } from "../../../../workspace-colors.js";
14
14
  export const workspaceTaskUsageInstruction = "Use workspace.tasks proactively for work that spans multiple steps or sessions: create or update Tasks to preserve useful next steps and current state, and read its summary when resuming relevant unfinished work. Do not query it mechanically on every open_workspace call.";
15
- export const capabilityDiscoveryInstruction = "For capability describe/run, use only names present in capabilityCatalog. capabilityFingerprint.capabilities is a semantic feature fingerprint, not a callable registry; capabilityGuides are documentation descriptors, not callable capabilities.";
15
+ export const capabilityDiscoveryInstruction = "Core MCP tools are separate from capabilityCatalog and capabilityFingerprint; the Host's current tools/list snapshot determines which top-level tools can be called. In toolMode=minimal or full, ForgeRelay's regular core surface is open_workspace, capability, close_workspace, read, write, edit, rename, delete, and bash. Do not infer that write, edit, or bash are unavailable merely because they are absent from capabilityCatalog or capabilityFingerprint. If an expected core tool is missing from the Host tool snapshot, treat the Host MCP metadata as stale or incomplete rather than describing ForgeRelay as read-only. For capability describe/run, use only names present in capabilityCatalog. capabilityFingerprint.capabilities is a semantic feature fingerprint, not a callable registry; capabilityGuides are documentation descriptors, not callable capabilities.";
16
16
  export async function presentLocalWorkspaceOpen(options, input, contextData) {
17
17
  const { config, forgerelayVersion: FORGERELAY_VERSION, workspaces, workspaceTasks, reviewCheckpoints, capabilityRegistry, subagentProviders, hooks, rememberWorkspacePanelState, } = options;
18
18
  const { path, workspaceId, mode, baseRef, targetBranch, newWorktree, newWorkspace, context } = input;
@@ -155,7 +155,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
155
155
  ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}`
156
156
  : undefined,
157
157
  capabilityCatalog.length > 0
158
- ? `Optional capabilities: ${capabilityCatalog.map((entry) => entry.name).join(", ")}`
158
+ ? `Capability catalog entries: ${capabilityCatalog.map((entry) => entry.name).join(", ")}`
159
159
  : undefined,
160
160
  visibleCapabilityGuides.length > 0
161
161
  ? `Capability guides: ${visibleCapabilityGuides.map((guide) => guide.name).join(", ")}`
@@ -175,7 +175,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
175
175
  staleWorkspaces.length > 0
176
176
  ? `This Workspace has been idle for more than 2 days: ${staleWorkspaces.map((stale) => `${stale.workspaceId} last-used=${stale.lastUsedAt}`).join(", ")}. It remains available to resume or explicitly close; do not clean it up automatically.`
177
177
  : undefined,
178
- `ForgeRelay ${capabilityFingerprint.version} capabilities: ${capabilityFingerprint.capabilities.join(", ")}`,
178
+ `ForgeRelay semantic feature fingerprint (${capabilityFingerprint.version}): ${capabilityFingerprint.capabilities.join(", ")}`,
179
179
  instruction,
180
180
  resourceUpdate?.text,
181
181
  ].filter(Boolean).join("\n"),
@@ -503,7 +503,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
503
503
  ? `Available skills: ${relayedSkills.map((skill) => String(skill.name ?? "")).filter(Boolean).join(", ")}`
504
504
  : undefined,
505
505
  relayedCapabilities.length > 0
506
- ? `Optional capabilities: ${relayedCapabilities.map((entry) => String(entry.name ?? "")).filter(Boolean).join(", ")}`
506
+ ? `Capability catalog entries: ${relayedCapabilities.map((entry) => String(entry.name ?? "")).filter(Boolean).join(", ")}`
507
507
  : undefined,
508
508
  opened.instruction,
509
509
  ].filter(Boolean).join("\n");
@@ -4,6 +4,7 @@ import { mcpHandlerRequestContext } from "../../../request-context.js";
4
4
  import { CapabilityError } from "../../core/capability-registry.js";
5
5
  import { attachHookReports, runToolWithHooks } from "../../../hooks/hooks.js";
6
6
  import { toolNames } from "../../../server-instructions.js";
7
+ import { rebaseRemoteCompositeConversationHealth } from "./conversation-health-relay.js";
7
8
  import { formatAgentsPath } from "../../../../workspaces.js";
8
9
  import { activityRelationFor, activityRequestFor, runActivityToolWithHooks, } from "../../core/activity-support.js";
9
10
  import { capabilityContextFor, compositeCapabilityContext, workspaceHookInvocation, } from "../../core/capability-support.js";
@@ -176,12 +177,17 @@ export function registerWorkspaceAuxiliaryTools(options) {
176
177
  const executionWorkspaceId = target.executionWorkspaceId;
177
178
  const executionContext = await prepareExecutionContext(target, requestContext);
178
179
  if (remoteWorkspaces.has(executionWorkspaceId)) {
179
- const response = await remoteWorkspaces.capability(executionWorkspaceId, {
180
+ let response = await remoteWorkspaces.capability(executionWorkspaceId, {
180
181
  name,
181
182
  action,
182
183
  ...(capabilityArguments !== undefined ? { arguments: capabilityArguments } : {}),
183
184
  ...(file !== undefined ? { file } : {}),
184
185
  }, hostScopeIdFor(requestContext));
186
+ if (action === "run"
187
+ && name === "workspace.conversation-health"
188
+ && target.compositeWorkspaceId) {
189
+ response = rebaseRemoteCompositeConversationHealth(response, target.compositeWorkspaceId, hostScopeIdFor(requestContext), activityQueries);
190
+ }
185
191
  return action === "run" && name !== "workspace.tasks"
186
192
  ? presentSemanticWorkResult(response, target, hostScopeIdFor(requestContext))
187
193
  : presentExecutionResult(response, target);
package/dist/server.js CHANGED
@@ -30,6 +30,8 @@ import { formatPathForPrompt } from "./workspaces/resources/skills.js";
30
30
  import { WorkspaceTaskReminderTracker } from "./workspaces/tasks/workspace-task-reminders.js";
31
31
  import { WorkspaceTaskStore } from "./workspaces/tasks/workspace-tasks.js";
32
32
  import { WorkspaceCheckpointStore } from "./workspaces/state/workspace-checkpoints.js";
33
+ import { inspectConversationHealth } from "./workspaces/state/conversation-health.js";
34
+ import { createWorkspaceCheckpointCapabilityRuntime, } from "./mcp/server/core/capabilities/workspace-checkpoint-runtime.js";
33
35
  import { compactCompositeWorkspacePresentation, compactRelayedWorkspacePresentation, compactWorkspacePresentation, } from "./workspaces/presentation/workspace-presentation.js";
34
36
  import { formatAgentsPath } from "./workspaces.js";
35
37
  import { summarizeSubagentProfile } from "./subagents/profiles.js";
@@ -148,32 +150,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
148
150
  value: await workspaces.runManagedWorktreeRecovery(context.workspaceId, input.operation),
149
151
  }),
150
152
  },
151
- workspaceCheckpoint: {
153
+ workspaceCheckpoint: createWorkspaceCheckpointCapabilityRuntime(workspaceCheckpoints),
154
+ workspaceConversationHealth: {
152
155
  available: true,
153
- run: async (input, context) => {
154
- const root = requireCapabilityWorkspaceRoot(context);
155
- switch (input.operation) {
156
- case "create":
157
- return {
158
- value: {
159
- workspaceId: context.workspaceId,
160
- checkpoint: await workspaceCheckpoints.create(context.workspaceId, root, input.name),
161
- ignoredFilesIncluded: false,
162
- },
163
- };
164
- case "list":
165
- return { value: await workspaceCheckpoints.list(context.workspaceId, root, input) };
166
- case "inspect":
167
- return { value: await workspaceCheckpoints.inspect(context.workspaceId, root, input.checkpointId) };
168
- case "restore.preflight":
169
- return { value: await workspaceCheckpoints.preflightRestore(context.workspaceId, root, input.checkpointId) };
170
- case "restore":
171
- return {
172
- value: await workspaceCheckpoints.restore(context.workspaceId, root, input.checkpointId, input.expectedCurrentSnapshot),
173
- };
174
- case "delete":
175
- return { value: await workspaceCheckpoints.delete(context.workspaceId, root, input.checkpointId) };
156
+ run: async (context, runOptions) => {
157
+ const conversationScopeId = runOptions.conversationScopeId;
158
+ if (!conversationScopeId) {
159
+ throw new CapabilityError("capability_unavailable", "workspace.conversation-health requires a resolved Host conversation scope.");
176
160
  }
161
+ return {
162
+ value: await inspectConversationHealth({ activityQueries, workspaceTasks, workspaceCheckpoints }, {
163
+ workspaceId: context.workspaceId,
164
+ workspaceRoot: requireCapabilityWorkspaceRoot(context),
165
+ conversationScopeId,
166
+ conversationWorkspaceId: runOptions.turnId
167
+ ? activityQueries.turnWorkspaceId(runOptions.turnId) ?? context.workspaceId
168
+ : context.workspaceId,
169
+ }),
170
+ };
177
171
  },
178
172
  },
179
173
  workspaceTasks: {
@@ -0,0 +1,124 @@
1
+ import { getGitEligibility, git } from "../git/git.js";
2
+ export const CONVERSATION_HEALTH_POLICY = {
3
+ checkpointTurnThreshold: 12,
4
+ rotationTurnThreshold: 24,
5
+ checkpointFreshTurnWindow: 8,
6
+ };
7
+ export async function inspectConversationHealth(dependencies, input) {
8
+ const conversationWorkspaceId = input.conversationWorkspaceId ?? input.workspaceId;
9
+ const hostTurnCount = dependencies.activityQueries.countTurns(input.conversationScopeId, conversationWorkspaceId);
10
+ const gitState = await inspectGitState(input.workspaceRoot);
11
+ const latestCheckpoint = gitState
12
+ ? (await dependencies.workspaceCheckpoints.latest(input.workspaceId, input.workspaceRoot)).checkpoint
13
+ : undefined;
14
+ const turnsSinceLatestCheckpoint = latestCheckpoint
15
+ ? dependencies.activityQueries.countTurns(input.conversationScopeId, conversationWorkspaceId, latestCheckpoint.createdAt)
16
+ : hostTurnCount;
17
+ const policyDecision = evaluateConversationHealth({
18
+ hostTurnCount,
19
+ turnsSinceLatestCheckpoint,
20
+ checkpointAvailable: Boolean(gitState),
21
+ checkpointPresent: Boolean(latestCheckpoint),
22
+ });
23
+ const taskHeaders = dependencies.workspaceTasks.inspectHeaders(input.workspaceId);
24
+ return {
25
+ workspaceId: input.workspaceId,
26
+ ...policyDecision,
27
+ policy: CONVERSATION_HEALTH_POLICY,
28
+ metrics: {
29
+ hostTurnCount,
30
+ turnsSinceLatestCheckpoint,
31
+ },
32
+ ...(latestCheckpoint ? { latestCheckpoint } : {}),
33
+ activeTasks: (taskHeaders?.lists ?? [])
34
+ .filter((list) => list.state === "active")
35
+ .flatMap((list) => list.tasks.flatMap((task) => task.status === "completed"
36
+ ? []
37
+ : [{
38
+ listId: list.id,
39
+ listName: list.name,
40
+ id: task.id,
41
+ status: task.status,
42
+ subject: task.subject,
43
+ }]))
44
+ .slice(0, 20),
45
+ ...(gitState ? { git: gitState } : {}),
46
+ };
47
+ }
48
+ export function evaluateConversationHealth(input) {
49
+ const checkpointRecent = input.checkpointPresent
50
+ && input.turnsSinceLatestCheckpoint
51
+ <= CONVERSATION_HEALTH_POLICY.checkpointFreshTurnWindow;
52
+ if (input.hostTurnCount >= CONVERSATION_HEALTH_POLICY.rotationTurnThreshold) {
53
+ if (!input.checkpointAvailable) {
54
+ return {
55
+ status: "rotation-recommended",
56
+ reasons: [
57
+ "host_turn_count_rotation_threshold",
58
+ "checkpoint_unavailable",
59
+ ],
60
+ recommendedAction: "rotate",
61
+ };
62
+ }
63
+ return {
64
+ status: "rotation-recommended",
65
+ reasons: [
66
+ "host_turn_count_rotation_threshold",
67
+ checkpointRecent ? "checkpoint_recent" : "checkpoint_stale",
68
+ ],
69
+ recommendedAction: checkpointRecent ? "rotate" : "checkpoint-and-rotate",
70
+ };
71
+ }
72
+ if (input.checkpointAvailable
73
+ && input.hostTurnCount >= CONVERSATION_HEALTH_POLICY.checkpointTurnThreshold
74
+ && !checkpointRecent) {
75
+ return {
76
+ status: "checkpoint-recommended",
77
+ reasons: [
78
+ "host_turn_count_checkpoint_threshold",
79
+ input.checkpointPresent ? "checkpoint_stale" : "checkpoint_missing",
80
+ ],
81
+ recommendedAction: "checkpoint",
82
+ };
83
+ }
84
+ return {
85
+ status: "healthy",
86
+ reasons: checkpointRecent ? ["checkpoint_recent"] : [],
87
+ recommendedAction: "continue",
88
+ };
89
+ }
90
+ export function rebaseConversationHealthAge(result, input) {
91
+ return {
92
+ ...result,
93
+ ...evaluateConversationHealth({
94
+ ...input,
95
+ checkpointAvailable: result.git !== undefined,
96
+ checkpointPresent: result.latestCheckpoint !== undefined,
97
+ }),
98
+ policy: CONVERSATION_HEALTH_POLICY,
99
+ metrics: {
100
+ hostTurnCount: input.hostTurnCount,
101
+ turnsSinceLatestCheckpoint: input.turnsSinceLatestCheckpoint,
102
+ },
103
+ };
104
+ }
105
+ async function inspectGitState(workspaceRoot) {
106
+ const eligibility = await getGitEligibility(workspaceRoot);
107
+ if (!eligibility.ok || !eligibility.gitRoot)
108
+ return undefined;
109
+ const [headResult, branchResult, statusResult] = await Promise.all([
110
+ git(eligibility.gitRoot, ["rev-parse", "HEAD"]),
111
+ git(eligibility.gitRoot, ["branch", "--show-current"]),
112
+ git(eligibility.gitRoot, ["status", "--porcelain=v1", "--untracked-files=normal"], { maxBuffer: 2 * 1024 * 1024 }),
113
+ ]);
114
+ const statusLines = statusResult.stdout
115
+ .split("\n")
116
+ .filter((line) => line.length > 0);
117
+ const branch = branchResult.stdout.trim();
118
+ return {
119
+ head: headResult.stdout.trim(),
120
+ ...(branch ? { branch } : {}),
121
+ dirty: statusLines.length > 0,
122
+ changedFiles: statusLines.length,
123
+ };
124
+ }
@@ -107,6 +107,21 @@ export class WorkspaceCheckpointStore {
107
107
  ignoredFilesIncluded: false,
108
108
  };
109
109
  }
110
+ async latest(workspaceId, workspaceRoot) {
111
+ const id = normalizeWorkspaceId(workspaceId);
112
+ const repository = await resolveRepository(workspaceRoot);
113
+ const state = this.tryReadState(id);
114
+ if (state)
115
+ await assertSameRepository(state.gitCommonDir, repository.gitCommonDir, id);
116
+ const checkpoint = state?.checkpoints.at(-1);
117
+ if (checkpoint)
118
+ await assertCheckpointRef(state.gitCommonDir, id, checkpoint);
119
+ return {
120
+ workspaceId: id,
121
+ ...(checkpoint ? { checkpoint: cloneCheckpoint(checkpoint) } : {}),
122
+ ignoredFilesIncluded: false,
123
+ };
124
+ }
110
125
  async inspect(workspaceId, workspaceRoot, checkpointId) {
111
126
  const id = normalizeWorkspaceId(workspaceId);
112
127
  const cpId = normalizeCheckpointId(checkpointId);
@@ -91,6 +91,11 @@ export class WorkspaceTaskStore {
91
91
  readHeaders(workspaceId, listId) {
92
92
  return taskHeaders(this.ensureWorkspace(workspaceId), listId);
93
93
  }
94
+ inspectHeaders(workspaceId) {
95
+ const id = normalizeWorkspaceId(workspaceId);
96
+ const loaded = this.tryReadState(id);
97
+ return loaded ? taskHeaders(snapshot(loaded.state, loaded.fingerprint)) : undefined;
98
+ }
94
99
  readTaskDetail(workspaceId, listId, taskId) {
95
100
  return taskDetail(this.ensureWorkspace(workspaceId), listId, taskId);
96
101
  }
@@ -453,7 +453,12 @@ version, tool mode, and stable semantic capability names. The fingerprint also
453
453
  reports enabled optional domains such as subagent profile discovery, native
454
454
  artifact download, MCP App UI, or aggregate `review.changes` when those
455
455
  features are actually available; it remains a semantic summary rather than a
456
- copy of `tools/list`.
456
+ copy of `tools/list`. Neither `capabilityFingerprint` nor `capabilityCatalog`
457
+ is evidence that an omitted Core tool is unavailable. The Host's current
458
+ `tools/list` snapshot is authoritative for top-level callability; in
459
+ `minimal` and `full`, the expected regular Core surface remains
460
+ `open_workspace`, `capability`, `close_workspace`, `read`, `write`,
461
+ `edit`, `rename`, `delete`, and `bash`.
457
462
 
458
463
  Bootstrap responses also return `capabilityGuides`, which are compact descriptors
459
464
  for built-in ForgeRelay documentation that the Agent can explicitly load with
@@ -466,11 +471,14 @@ context.
466
471
 
467
472
  There is no separate progressive-disclosure configuration switch. Capability
468
473
  Guide discovery is built in, while optional low-frequency actions are advertised
469
- through the workspace Capability catalog instead of adding top-level tools. If the
470
- fingerprint reports a capability that is missing from the Host's current tool
471
- snapshot, treat that as stale Host MCP metadata: reconnect/refresh the integration
472
- or use a Host context that reloads `tools/list`. The ForgeRelay process cannot
473
- force a Host to invalidate its cached schema.
474
+ through the workspace Capability catalog instead of adding top-level tools. If an
475
+ expected Core tool is missing from the Host's current tool snapshot, or the
476
+ fingerprint reports a semantic feature that the Host metadata does not reflect,
477
+ treat that as stale or incomplete Host MCP metadata: reconnect/refresh the
478
+ integration or use a Host context that reloads `tools/list`. Do not describe
479
+ ForgeRelay as read-only merely because `write`, `edit`, or `bash` is absent
480
+ from the fingerprint or Capability catalog. The ForgeRelay process cannot force
481
+ a Host to invalidate its cached schema.
474
482
 
475
483
  ### LSP code intelligence
476
484
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",