@akira-tl/forgerelay 1.3.1 → 1.3.2

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.2] - 2026-10-08
8
+
9
+ ### Security
10
+
11
+ - 现在对所有 Activity App 查询校验 Host Turn 的对话归属,阻止知道其他会话 `turnId` 的调用者通过 `activity_snapshot`、`activity_index`、`activity_detail` 或 `activity_output` 读取跨会话操作历史、命令内容及输出。
12
+ - Workspace Relay 网关在转发 Activity 查询前验证会话与远端 Turn 的归属关系;即使远端尚未升级,也不能通过已知的跨会话 Turn 路由读取历史。为本地、Relay 和重启路径补充回归覆盖。
13
+
14
+ ### Fixed
15
+
16
+ - 修复 Bash 进程的非交互等待轮询可能过早返回或被高频零等待查询绕过的问题;有界等待及带缓存输出的行为由确定性测试覆盖,同时保留交互输入的短等待行为。
17
+ - Workspace Relay 现在为较长的进程等待窗口留出请求截止时间余量,并与本地进程等待共享计算规则,避免在远端等待刚到边界时请求提前超时。
18
+ - Shell 工作流指令在 MCP server instructions 中继续保持可见;重复打开 Workspace 时强化调用 `activity_panel` 建立 Host Turn 的提醒,但不自动创建 Panel,也不改变现有显式面板生命周期。
19
+
7
20
  ## [1.3.1] - 2026-09-18
8
21
 
9
22
  ### Fixed
@@ -130,6 +130,12 @@ export class ActivityQueryService {
130
130
  : summary;
131
131
  });
132
132
  }
133
+ assertConversationTurn(turnId, conversationScopeId) {
134
+ const turn = this.turns.get(turnId);
135
+ if (!turn || turn.conversationScopeId !== conversationScopeId) {
136
+ throw new Error(`Unknown Host Turn: ${turnId}.`);
137
+ }
138
+ }
133
139
  requireTurn(turnId) {
134
140
  if (!this.turns.get(turnId))
135
141
  throw new Error(`Unknown Host Turn: ${turnId}.`);
@@ -143,6 +143,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
143
143
  if (!resolvedTurnId) {
144
144
  throw new Error("Activity snapshot bootstrap could not resolve the current Host Turn from conversation and workspace metadata.");
145
145
  }
146
+ queries.assertConversationTurn(resolvedTurnId, conversationScopeId);
146
147
  const snapshot = queries.state(resolvedTurnId, knownRevision);
147
148
  if (logging) {
148
149
  logEvent(logging, "debug", "activity_snapshot_call", {
@@ -184,6 +185,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
184
185
  const relayed = await relay?.index(turnId, knownRevision, conversationScopeId);
185
186
  if (relayed)
186
187
  return relayed;
188
+ queries.assertConversationTurn(turnId, conversationScopeId);
187
189
  const index = queries.index(turnId, knownRevision);
188
190
  if (logging) {
189
191
  logEvent(logging, "debug", "activity_index_call", {
@@ -222,6 +224,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
222
224
  const relayed = await relay?.detail(turnId, activityId, conversationScopeId);
223
225
  if (relayed)
224
226
  return relayed;
227
+ queries.assertConversationTurn(turnId, conversationScopeId);
225
228
  const detail = queries.detail(turnId, activityId);
226
229
  if (logging) {
227
230
  logEvent(logging, "debug", "activity_detail_call", {
@@ -268,6 +271,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
268
271
  const relayed = await relay?.output(turnId, outputId, conversationScopeId, cursor);
269
272
  if (relayed)
270
273
  return relayed;
274
+ queries.assertConversationTurn(turnId, conversationScopeId);
271
275
  const output = queries.bashOutput(turnId, outputId, cursor);
272
276
  if (logging) {
273
277
  logEvent(logging, "debug", "activity_output_call", {
@@ -1,13 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { releasePtyProcessResources, resolveShellCommandForRuntime, terminateProcessTree, terminatePtyProcessTree } from "./process-platform.js";
3
3
  import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime } from "../../runtime/shell/command-shell-runtime.js";
4
- const DEFAULT_EXEC_YIELD_MS = 10_000;
5
- const DEFAULT_INTERACTIVE_YIELD_MS = 250;
6
- export const DEFAULT_POLL_YIELD_MS = 60_000;
7
- const MAX_START_YIELD_MS = 300_000;
8
- const MAX_COMMAND_YIELD_MS = 300_000;
9
- const MAX_POLL_YIELD_MS = 300_000;
10
- const MAX_EXECUTION_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
4
+ import { DEFAULT_EXEC_YIELD_MS, DEFAULT_INTERACTIVE_YIELD_MS, MAX_COMMAND_YIELD_MS, MAX_START_YIELD_MS, boundedDuration, executionTimeout, minimumPollYield, waitOnlyYieldMs, } from "./process-wait-policy.js";
5
+ export { DEFAULT_POLL_YIELD_MS } from "./process-wait-policy.js";
11
6
  const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
12
7
  const DEFAULT_BUFFER_CHARACTERS = 256_000;
13
8
  const DEFAULT_MAX_ACTIVE_PROCESSES = 64;
@@ -18,22 +13,6 @@ const COMPACT_COMPLETION_CHARACTERS = 16 * 1024;
18
13
  const COMPACT_COMPLETION_OUTPUT_TOKENS = COMPACT_COMPLETION_CHARACTERS / 4;
19
14
  const DEFAULT_COLUMNS = 80;
20
15
  const DEFAULT_ROWS = 24;
21
- function boundedInteger(value, fallback, maximum) {
22
- if (value === undefined)
23
- return Math.min(fallback, maximum);
24
- if (!Number.isFinite(value) || value < 0) {
25
- throw new Error("Duration and output limits must be non-negative.");
26
- }
27
- return Math.min(Math.floor(value), maximum);
28
- }
29
- function optionalExecutionTimeout(value) {
30
- if (value === undefined)
31
- return undefined;
32
- if (!Number.isInteger(value) || value < 1 || value > MAX_EXECUTION_TIMEOUT_MS) {
33
- throw new Error(`Execution timeout must be an integer between 1 and ${MAX_EXECUTION_TIMEOUT_MS}ms.`);
34
- }
35
- return value;
36
- }
37
16
  function terminalSize(value, fallback) {
38
17
  if (value === undefined)
39
18
  return fallback;
@@ -209,6 +188,7 @@ export class ProcessManager {
209
188
  maxCompletedProcesses;
210
189
  completedProcessTtlMs;
211
190
  maxStartYieldMs;
191
+ minimumPollYieldMs;
212
192
  monotonicNow;
213
193
  outputAudit;
214
194
  commandShellRuntime;
@@ -227,6 +207,7 @@ export class ProcessManager {
227
207
  ?? options.completedSessionTtlMs
228
208
  ?? COMPLETED_PROCESS_TTL_MS;
229
209
  this.maxStartYieldMs = options.maxStartYieldMs ?? MAX_START_YIELD_MS;
210
+ this.minimumPollYieldMs = minimumPollYield(options.minimumPollYieldMs);
230
211
  this.monotonicNow = options.monotonicNow ?? (() => performance.now());
231
212
  this.outputAudit = options.outputAudit;
232
213
  this.commandShellRuntime = snapshotCommandShellRuntime(options.commandShellRuntime ?? resolveCompatibilityCommandShellRuntime());
@@ -236,7 +217,7 @@ export class ProcessManager {
236
217
  if (this.stats().running >= this.maxActiveProcesses) {
237
218
  throw new Error(`Active process limit reached (${this.maxActiveProcesses}). Poll, interrupt, or wait for an existing process before starting another.`);
238
219
  }
239
- const executionTimeoutMs = optionalExecutionTimeout(input.timeoutMs);
220
+ const executionTimeoutMs = executionTimeout(input.timeoutMs);
240
221
  const processEntry = this.createProcess(input);
241
222
  this.processes.set(processEntry.id, processEntry);
242
223
  try {
@@ -254,7 +235,7 @@ export class ProcessManager {
254
235
  throw error;
255
236
  }
256
237
  this.armExecutionTimeout(processEntry, executionTimeoutMs);
257
- const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, this.maxStartYieldMs);
238
+ const yieldTimeMs = boundedDuration(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, this.maxStartYieldMs);
258
239
  try {
259
240
  await this.waitForExit(processEntry, yieldTimeMs, input.signal);
260
241
  input.signal?.throwIfAborted();
@@ -293,12 +274,22 @@ export class ProcessManager {
293
274
  const writableChars = chars.replaceAll("\u0003", "");
294
275
  if (writableChars && processEntry.running)
295
276
  processEntry.process?.write(writableChars);
296
- const explicitWaitRequested = input.yieldTimeMs !== undefined;
297
- if ((explicitWaitRequested || interactionRequested || !processEntry.buffer.hasOutput()) && processEntry.running) {
298
- const fallback = interactionRequested ? DEFAULT_INTERACTIVE_YIELD_MS : DEFAULT_POLL_YIELD_MS;
299
- const maximum = interactionRequested ? MAX_COMMAND_YIELD_MS : MAX_POLL_YIELD_MS;
300
- const yieldTimeMs = boundedInteger(input.yieldTimeMs, fallback, maximum);
301
- await this.waitForExit(processEntry, yieldTimeMs, input.signal);
277
+ if (processEntry.running) {
278
+ if (interactionRequested) {
279
+ const yieldTimeMs = boundedDuration(input.yieldTimeMs, DEFAULT_INTERACTIVE_YIELD_MS, MAX_COMMAND_YIELD_MS);
280
+ await this.waitForExit(processEntry, yieldTimeMs, input.signal);
281
+ }
282
+ else if (input.yieldTimeMs === 0) {
283
+ if (processEntry.waitOnlyProbeUsed) {
284
+ throw new Error(`Immediate wait-only status probe for process ${processEntry.id} was already used. ` +
285
+ "Use a positive or omitted yieldTimeMs for subsequent waits.");
286
+ }
287
+ processEntry.waitOnlyProbeUsed = true;
288
+ }
289
+ else {
290
+ const yieldTimeMs = waitOnlyYieldMs(input.yieldTimeMs, this.minimumPollYieldMs);
291
+ await this.waitForExit(processEntry, yieldTimeMs, input.signal);
292
+ }
302
293
  }
303
294
  const snapshot = this.consume(processEntry, input.maxOutputTokens);
304
295
  if (!processEntry.running)
@@ -433,6 +424,7 @@ export class ProcessManager {
433
424
  outputWasTruncated: false,
434
425
  background: false,
435
426
  discardOnFinish: false,
427
+ waitOnlyProbeUsed: false,
436
428
  exitPromise,
437
429
  resolveExit,
438
430
  };
@@ -555,7 +547,7 @@ export class ProcessManager {
555
547
  this.outputAudit?.finish(processEntry.outputId, input);
556
548
  }
557
549
  consume(processEntry, maxOutputTokens) {
558
- const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000);
550
+ const limit = boundedDuration(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000);
559
551
  const maxCharacters = Math.max(256, limit * 4);
560
552
  const buffered = processEntry.buffer.drain(maxCharacters);
561
553
  if (buffered.truncated)
@@ -0,0 +1,34 @@
1
+ export const DEFAULT_EXEC_YIELD_MS = 10_000;
2
+ export const DEFAULT_INTERACTIVE_YIELD_MS = 250;
3
+ export const DEFAULT_POLL_YIELD_MS = 60_000;
4
+ export const MAX_START_YIELD_MS = 300_000;
5
+ export const MAX_COMMAND_YIELD_MS = 300_000;
6
+ export const MAX_POLL_YIELD_MS = 300_000;
7
+ const MAX_EXECUTION_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
8
+ export function boundedDuration(value, fallback, maximum) {
9
+ if (value === undefined)
10
+ return Math.min(fallback, maximum);
11
+ if (!Number.isFinite(value) || value < 0) {
12
+ throw new Error("Duration and output limits must be non-negative.");
13
+ }
14
+ return Math.min(Math.floor(value), maximum);
15
+ }
16
+ export function executionTimeout(value) {
17
+ if (value === undefined)
18
+ return undefined;
19
+ if (!Number.isInteger(value) || value < 1 || value > MAX_EXECUTION_TIMEOUT_MS) {
20
+ throw new Error(`Execution timeout must be an integer between 1 and ${MAX_EXECUTION_TIMEOUT_MS}ms.`);
21
+ }
22
+ return value;
23
+ }
24
+ export function minimumPollYield(value) {
25
+ const resolved = value ?? DEFAULT_POLL_YIELD_MS;
26
+ if (!Number.isInteger(resolved) || resolved < 1 || resolved > MAX_POLL_YIELD_MS) {
27
+ throw new Error(`Minimum poll yield must be an integer between 1 and ${MAX_POLL_YIELD_MS}ms.`);
28
+ }
29
+ return resolved;
30
+ }
31
+ export function waitOnlyYieldMs(requested, minimum = DEFAULT_POLL_YIELD_MS) {
32
+ const resolvedMinimum = minimumPollYield(minimum);
33
+ return Math.max(resolvedMinimum, boundedDuration(requested, resolvedMinimum, MAX_POLL_YIELD_MS));
34
+ }
@@ -41,7 +41,7 @@ function registerBashTool(options) {
41
41
  columns: z.number().int().min(1).max(1_000).optional().describe("Initial PTY width for action=run, or resize width for action=process."),
42
42
  rows: z.number().int().min(1).max(1_000).optional().describe("Initial PTY height for action=run, or resize height for action=process."),
43
43
  workingDirectory: z.string().optional().describe("For action=run, working directory relative to the workspace root. Defaults to the workspace root."),
44
- yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("Maximum feedback wait, not a minimum delay: if the process finishes sooner, the call returns immediately. For long-running commands or wait-only action=process calls, set a long window near the Host request deadline (60000ms when supported) instead of repeated short polling. For action=run, use 0 for immediate background handoff; otherwise defaults to 10000ms. For action=process, wait-only calls default to 60000ms and interaction to 250ms."),
44
+ yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("Feedback wait. action=run defaults to 10000ms; 0 hands off immediately. Wait-only action=process defaults to 60000ms; positive shorter values are raised to 60000ms and buffered output does not shorten the wait. Use 0 once per running process for one immediate wait-only status probe; repeated wait-only 0 is rejected. Interactive action=process defaults to 250ms. Process exit returns sooner."),
45
45
  timeoutMs: z.number().int().min(1).max(86_400_000).optional().describe("For action=run, total execution timeout from process start. On expiry ForgeRelay terminates the process. Omit for no ForgeRelay execution deadline."),
46
46
  maxOutputTokens: z.number().int().positive().max(100_000).optional().describe("Approximate output token budget. Defaults to 10000."),
47
47
  },
@@ -232,7 +232,7 @@ function registerCodexProcessTools(options) {
232
232
  chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."),
233
233
  columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."),
234
234
  rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."),
235
- yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("Milliseconds to keep waiting before returning again, max 300000. Polling defaults to 60000; interaction defaults to 250."),
235
+ yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("Wait window, max 300000ms. Wait-only polling defaults to 60000ms; positive shorter values are raised to 60000ms and buffered output does not shorten the wait. Use 0 once per running process for one immediate wait-only status probe; repeated wait-only 0 is rejected. Interaction defaults to 250ms; process exit returns sooner."),
236
236
  maxOutputTokens: z.number().int().positive().max(100_000).optional().describe("Approximate output token budget. Defaults to 10000."),
237
237
  },
238
238
  outputSchema: processOutputSchema(),
@@ -97,6 +97,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
97
97
  const availableAgentsFileOutputs = bootstrapComponents.has("availableAgentsFiles")
98
98
  ? cardAvailableAgentsFiles
99
99
  : [];
100
+ const panelTurnReminder = `Before the first non-lifecycle ForgeRelay work tool after this Workspace resolution, call activity_panel(workspaceId=\"${workspace.id}\") exactly once to begin the project Host Turn. This is required even when the Workspace was already open or reused.`;
100
101
  const workspaceContextInstruction = "For later open_workspace calls, context=\"auto\" avoids repeating unchanged bootstrap context; use context=\"none\" when only the workspace handle/metadata is needed, or context=\"full\" to force a refresh.";
101
102
  const workspaceManagementInstruction = [
102
103
  "Use open_workspace(action=\"list\") for lightweight Workspace inventory. Use action=\"inspect\" with one known workspaceId for bounded read-only metadata without opening/resuming it. Explicitly open a Workspace before executing or mutating against it, and ask the user before close_workspace cleanup.",
@@ -106,12 +107,13 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
106
107
  : undefined,
107
108
  ].filter(Boolean).join(" ");
108
109
  const cardInstruction = config.skillsEnabled
109
- ? `Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches an available skill, load it with read(path=\"skills://<name>\") before proceeding. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction} ${executionContext.agentInstruction}`
110
- : `Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction} ${executionContext.agentInstruction}`;
110
+ ? `${panelTurnReminder} Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches an available skill, load it with read(path=\"skills://<name>\") before proceeding. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction} ${executionContext.agentInstruction}`
111
+ : `${panelTurnReminder} Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction} ${executionContext.agentInstruction}`;
111
112
  const instruction = workspaceReused
112
113
  ? effectiveIncludeBootstrapContext
113
114
  ? [
114
115
  `Workspace already exists as ${workspace.id} for this directory.`,
116
+ panelTurnReminder,
115
117
  "Reuse this workspaceId for subsequent tool calls.",
116
118
  effectiveBootstrapContextComponents.length > 0
117
119
  ? `Project bootstrap context components included in this response: ${effectiveBootstrapContextComponents.join(", ")}. Components not listed are unchanged and are not repeated.`
@@ -122,6 +124,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
122
124
  ].join("\n\n")
123
125
  : [
124
126
  `Workspace already open as ${workspace.id}.`,
127
+ panelTurnReminder,
125
128
  "Reuse this workspaceId for subsequent tool calls. This is the same directory previously opened in this conversation.",
126
129
  "Continue following the project instructions, nested instruction files, skills, capability guides, agent profiles, and diagnostics previously provided for this workspace. They remain active and are not repeated here.",
127
130
  workspaceContextInstruction,
@@ -129,7 +132,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
129
132
  executionContext.agentInstruction,
130
133
  ].join("\n\n")
131
134
  : workspace.mode === "worktree"
132
- ? `Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for this isolated worktree. ${workspaceManagementInstruction} ${executionContext.agentInstruction}`
135
+ ? `${panelTurnReminder} Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for this isolated worktree. ${workspaceManagementInstruction} ${executionContext.agentInstruction}`
133
136
  : cardInstruction;
134
137
  const resultContent = [
135
138
  {
@@ -53,7 +53,7 @@ export function buildToolDescriptions(config) {
53
53
  rename: `Rename or move one file or directory inside an open workspace or the OS temp directory without overwriting an existing destination. Source and destination must both remain inside the permitted file roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
54
54
  delete: `Delete one path or multiple paths inside an open workspace or the OS temp directory. Use path for one target or paths for multiple targets; a bulk Delete preflights all targets before deleting anything. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
55
55
  applyPatch: `Apply one Codex-style patch inside an open workspace or the OS temp directory. Supports adding, overwriting, updating, deleting, and moving files. Workspace paths must remain relative; absolute paths are accepted only inside the OS temp directory. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
56
- shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands run with the local user's authority; workspace containment does not make shell execution a sandbox. For action=run, yieldTimeMs is the feedback wait (default 10000ms; 0 returns processId) and timeoutMs is the independent execution limit. action=process waits/interacts with processId. For long-running commands or wait-only process calls, set a long yieldTimeMs near the Host deadline (60000ms when supported), not short polling. This wait is a maximum: if the process finishes sooner, the call returns immediately. If still running, reuse processId with another long wait; use short waits only for interaction. Background completions can arrive later. Call ${toolNames.openWorkspace} first with workspaceId. Expose only behind strong authentication.`,
56
+ shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands run with the local user's authority; workspace containment does not make shell execution a sandbox. action=run uses yieldTimeMs as feedback wait (default 10000ms; 0 returns processId) and timeoutMs as the execution limit. Wait-only action=process uses at least 60000ms for positive yieldTimeMs, even with buffered output; 0 is one immediate probe per running process and repeats are rejected. Process exit returns sooner; short waits are for interaction. Background completions can arrive later. Call ${toolNames.openWorkspace} first with workspaceId. Expose only behind strong authentication.`,
57
57
  shellCommand: "Shell command to run with the local user's authority.",
58
58
  };
59
59
  }
@@ -70,7 +70,6 @@ function capabilityContractInstructions(config) {
70
70
  ? ""
71
71
  : ` If ${toolNames.openWorkspace} reports stale workspaces, let the user choose resume or ${toolNames.closeWorkspace}; never auto-close.`;
72
72
  const workspaceLifecycle = `Default to the user's existing checkout. Reuse workspaceId from ${toolNames.openWorkspace}; change it only when asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} preserves Workspace identity. Managed close finalizes backing and needs commitMessage. Composite close preserves members; delete removes only Composite state. Active worktree delete still finalizes safely; checkout files are never deleted.`;
73
- const activityPanel = `Project-work order: ${toolNames.openWorkspace} if needed → activity_panel(workspaceId) once → work tools. activity_panel is the single ForgeRelay UI render tool: Workspace above Activity. A new workspaceId creates a new card. Never call activity_panel before needed ${toolNames.openWorkspace}.`;
74
73
  const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
75
74
  const capabilityGuides = `For optional capabilities from ${toolNames.openWorkspace}, use ${toolNames.capability}; if unfamiliar, describe first and read its advertised capability guide with ${toolNames.read}.`;
76
75
  const skills = config.skillsEnabled
@@ -78,7 +77,7 @@ function capabilityContractInstructions(config) {
78
77
  : "";
79
78
  const shellMutationPolicy = buildShellMutationPolicy();
80
79
  const hooks = "When a ForgeRelay tool result reports Hook results, tell the user which meaningful hooks ran and whether they passed or blocked the operation. Do not claim the requested operation succeeded when a blocking hook prevented it.";
81
- return joinInstructions(workspaceLifecycle, activityPanel, agents, capabilityGuides, skills, shellMutationPolicy, hooks);
80
+ return joinInstructions(workspaceLifecycle, agents, capabilityGuides, skills, shellMutationPolicy, hooks);
82
81
  }
83
82
  function selectedWorkflowInstructions(config) {
84
83
  if (config.workflowInstructions === false)
@@ -89,10 +88,10 @@ function selectedWorkflowInstructions(config) {
89
88
  }
90
89
  function defaultWorkflowInstructions(config) {
91
90
  if (config.toolMode === "codex") {
92
- return `Use ${toolNames.read} for direct file reads, ${toolNames.rename} and ${toolNames.delete} for direct path moves or removals, apply_patch for content modifications, exec_command for inspection, tests, builds, and other commands, and ${toolNames.writeStdin} to poll or interact with running processes.`;
91
+ return `Use ${toolNames.read} for direct file reads, ${toolNames.rename} and ${toolNames.delete} for direct path moves or removals, apply_patch for content modifications, exec_command for inspection, tests, builds, and other commands, and ${toolNames.writeStdin} to poll or interact with running processes. Wait-only polling uses at least 60000ms for positive yieldTimeMs even with buffered output; 0 is one immediate probe per running process and repeats are rejected. Short waits are for interaction.`;
93
92
  }
94
93
  const inspection = `Use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection.`;
95
- return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, Git/package scripts, generators, formatters, and shell-suited commands. For long ${toolNames.shell} commands or wait-only calls, set yieldTimeMs near Host deadline (60000ms when supported); do not poll every few seconds. Completion returns immediately if sooner; if still running, reuse processId with a long wait. Short waits are only for interaction.`);
94
+ return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, Git/package scripts, generators, formatters, and shell-suited commands. Wait-only polling uses at least 60000ms for positive yieldTimeMs even with buffered output; 0 is one immediate probe per running process and repeats are rejected. Process exit returns sooner; short waits are for interaction.`);
96
95
  }
97
96
  function joinInstructions(...parts) {
98
97
  return parts
@@ -0,0 +1,42 @@
1
+ import { DEFAULT_REQUEST_TIMEOUT_MSEC } from "@modelcontextprotocol/client";
2
+ import { DEFAULT_EXEC_YIELD_MS, DEFAULT_INTERACTIVE_YIELD_MS, DEFAULT_POLL_YIELD_MS, } from "../../../mcp/process/process-wait-policy.js";
3
+ const REMOTE_WAIT_HEADROOM_MS = 5_000;
4
+ export function remoteToolCallTimeoutMs(name, args) {
5
+ const waitMs = remoteToolWaitMs(name, args);
6
+ if (waitMs === undefined || waitMs < DEFAULT_REQUEST_TIMEOUT_MSEC)
7
+ return undefined;
8
+ return waitMs + REMOTE_WAIT_HEADROOM_MS;
9
+ }
10
+ function remoteToolWaitMs(name, args) {
11
+ const requested = numberField(args.yieldTimeMs);
12
+ if (name === "bash") {
13
+ const action = typeof args.action === "string" ? args.action : "run";
14
+ if (action === "output")
15
+ return undefined;
16
+ if (action === "run")
17
+ return requested ?? DEFAULT_EXEC_YIELD_MS;
18
+ if (action !== "process")
19
+ return undefined;
20
+ return processControlWaitMs(requested, typeof args.input === "string" && args.input.length > 0 ||
21
+ args.interrupt === true ||
22
+ args.columns !== undefined ||
23
+ args.rows !== undefined);
24
+ }
25
+ if (name === "exec_command")
26
+ return requested ?? DEFAULT_EXEC_YIELD_MS;
27
+ if (name !== "write_stdin" || args.outputId !== undefined)
28
+ return undefined;
29
+ return processControlWaitMs(requested, typeof args.chars === "string" && args.chars.length > 0 ||
30
+ args.columns !== undefined ||
31
+ args.rows !== undefined);
32
+ }
33
+ function processControlWaitMs(requested, interactionRequested) {
34
+ if (interactionRequested)
35
+ return requested ?? DEFAULT_INTERACTIVE_YIELD_MS;
36
+ if (requested === 0)
37
+ return 0;
38
+ return Math.max(DEFAULT_POLL_YIELD_MS, requested ?? DEFAULT_POLL_YIELD_MS);
39
+ }
40
+ function numberField(value) {
41
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
42
+ }
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import { CompatibilityCallToolResultSchema } from "@modelcontextprotocol/core";
5
5
  import { isRemoteMcpUnauthorized, refreshRemoteAuthentication, } from "./auth/remote-auth.js";
6
6
  import { RemoteMcpConnectionPool } from "./transport/remote-mcp-connection-pool.js";
7
+ import { remoteToolCallTimeoutMs } from "./transport/remote-call-timeout.js";
7
8
  import { withFileLock } from "../../runtime/state/lock/file-lock.js";
8
9
  import { withRemoteServiceEndpoint } from "./transport/remote-transport.js";
9
10
  import { loadForgeRelayFiles, writeForgeRelayRemote, } from "../../runtime/config/user-config.js";
@@ -227,15 +228,22 @@ export class RemoteWorkspaceRelay {
227
228
  throw new Error(`Remote activity_panel failed: ${toolResultText(result)}`);
228
229
  }
229
230
  const turnId = stringField(result.structuredContent, "turnId", "Remote activity_panel response");
230
- this.turnRoutes.set(turnId, gatewayWorkspaceId);
231
+ this.turnRoutes.set(turnId, { workspaceId: gatewayWorkspaceId, conversationScopeId });
231
232
  return result;
232
233
  }
233
234
  async activitySnapshot(input, conversationScopeId) {
235
+ const turnRoute = input.turnId ? this.turnRoutes.get(input.turnId) : undefined;
236
+ if (input.turnId && !turnRoute)
237
+ return undefined;
238
+ if (turnRoute && turnRoute.conversationScopeId !== conversationScopeId) {
239
+ throw new Error(`Unknown Host Turn: ${input.turnId}.`);
240
+ }
234
241
  const gatewayWorkspaceId = input.workspaceId && this.has(input.workspaceId)
235
242
  ? input.workspaceId
236
- : input.turnId
237
- ? this.turnRoutes.get(input.turnId)
238
- : undefined;
243
+ : turnRoute?.workspaceId;
244
+ if (turnRoute && turnRoute.workspaceId !== gatewayWorkspaceId) {
245
+ throw new Error(`Unknown Host Turn: ${input.turnId}.`);
246
+ }
239
247
  if (!gatewayWorkspaceId)
240
248
  return undefined;
241
249
  const route = this.requireRoute(gatewayWorkspaceId);
@@ -248,7 +256,10 @@ export class RemoteWorkspaceRelay {
248
256
  }, conversationScopeId);
249
257
  const remapped = remapToolResultWorkspaceId(result, route.remoteWorkspaceId, gatewayWorkspaceId);
250
258
  const turnId = stringField(remapped.structuredContent, "turnId", "Remote activity_snapshot response");
251
- this.turnRoutes.set(turnId, gatewayWorkspaceId);
259
+ if (input.turnId && turnId !== input.turnId) {
260
+ throw new Error(`Unknown Host Turn: ${input.turnId}.`);
261
+ }
262
+ this.turnRoutes.set(turnId, { workspaceId: gatewayWorkspaceId, conversationScopeId });
252
263
  return remapped;
253
264
  }
254
265
  catch (error) {
@@ -265,9 +276,13 @@ export class RemoteWorkspaceRelay {
265
276
  return this.callTurnTool(turnId, "activity_output", { turnId, outputId, ...(cursor !== undefined ? { cursor } : {}) }, conversationScopeId);
266
277
  }
267
278
  async callTurnTool(turnId, name, args, conversationScopeId) {
268
- const gatewayWorkspaceId = this.turnRoutes.get(turnId);
269
- if (!gatewayWorkspaceId)
279
+ const turnRoute = this.turnRoutes.get(turnId);
280
+ if (!turnRoute)
270
281
  return undefined;
282
+ if (turnRoute.conversationScopeId !== conversationScopeId) {
283
+ throw new Error(`Unknown Host Turn: ${turnId}.`);
284
+ }
285
+ const gatewayWorkspaceId = turnRoute.workspaceId;
271
286
  const route = this.requireRoute(gatewayWorkspaceId);
272
287
  const resolved = this.remoteByInstance(route.remoteInstanceId);
273
288
  try {
@@ -313,8 +328,8 @@ export class RemoteWorkspaceRelay {
313
328
  this.routes.delete(gatewayWorkspaceId);
314
329
  await this.deletePersistedRoute(gatewayWorkspaceId);
315
330
  }
316
- for (const [turnId, routedWorkspaceId] of this.turnRoutes) {
317
- if (routedWorkspaceId === gatewayWorkspaceId)
331
+ for (const [turnId, turnRoute] of this.turnRoutes) {
332
+ if (turnRoute.workspaceId === gatewayWorkspaceId)
318
333
  this.turnRoutes.delete(turnId);
319
334
  }
320
335
  const actionText = action === "delete" ? "Deleted" : "Closed";
@@ -469,13 +484,14 @@ export class RemoteWorkspaceRelay {
469
484
  }
470
485
  }
471
486
  const invoke = async (active) => {
487
+ const requestTimeoutMs = remoteToolCallTimeoutMs(name, args);
472
488
  const parsed = CompatibilityCallToolResultSchema.parse(await active.client.callTool({
473
489
  name,
474
490
  arguments: args,
475
491
  ...(conversationScopeId
476
492
  ? { _meta: { "openai/session": conversationScopeId } }
477
493
  : {}),
478
- }));
494
+ }, requestTimeoutMs === undefined ? undefined : { timeout: requestTimeoutMs }));
479
495
  if (!Array.isArray(parsed.content)) {
480
496
  throw new Error("Remote ForgeRelay returned a modern-only tool result on the legacy Relay protocol path.");
481
497
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
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",
@@ -59,7 +59,7 @@ export async function runBootstrapAcceptance({ server, packageJson, ownerToken,
59
59
  assert.match(serverInstructions, /Shell commands may modify ordinary project files/);
60
60
  assert.match(serverInstructions, /\/etc\/sudoers/);
61
61
  assert.match(serverInstructions, /configuration files through shell only when the user's request explicitly calls for that configuration change/);
62
- assert.match(serverInstructions, /Project-work order: open_workspace if needed → activity_panel\(workspaceId\) once → work tools/);
62
+ assert.doesNotMatch(serverInstructions, /activity_panel/);
63
63
  assert.ok(serverInstructions.length < 3_000, `server instructions should stay compact, got ${serverInstructions.length} characters`);
64
64
  assert.doesNotMatch(serverInstructions, /fast-forwards the original target branch/);
65
65
  assert.doesNotMatch(serverInstructions, /Do not create or modify files with bash/);