@akira-tl/forgerelay 0.4.6 → 0.4.7

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/dist/server.js CHANGED
@@ -469,15 +469,19 @@ async function readWorkspaceAppResource(config, requestedUri, transportSessionId
469
469
  function processResult(snapshot) {
470
470
  const status = snapshot.running
471
471
  ? `Process running with process ID ${snapshot.processId}.`
472
- : snapshot.signal
473
- ? `Process exited after signal ${snapshot.signal}.`
474
- : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`;
472
+ : snapshot.timedOut
473
+ ? "Process timed out and was terminated."
474
+ : snapshot.signal
475
+ ? `Process exited after signal ${snapshot.signal}.`
476
+ : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`;
475
477
  return snapshot.output ? `${snapshot.output.replace(/\n$/, "")}\n${status}` : status;
476
478
  }
477
479
  function completedProcessResult(snapshot) {
478
- const status = snapshot.signal
479
- ? `Background process ${snapshot.processId} exited after signal ${snapshot.signal}.`
480
- : `Background process ${snapshot.processId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
480
+ const status = snapshot.timedOut
481
+ ? `Background process ${snapshot.processId} timed out and was terminated.`
482
+ : snapshot.signal
483
+ ? `Background process ${snapshot.processId} exited after signal ${snapshot.signal}.`
484
+ : `Background process ${snapshot.processId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
481
485
  const command = `Command: ${snapshot.command}`;
482
486
  const output = snapshot.output ? `\n${snapshot.output.replace(/\n$/, "")}` : "";
483
487
  return `${status}\n${command}${output}`;
@@ -524,6 +528,7 @@ function processOutputSchema() {
524
528
  running: z.boolean(),
525
529
  exitCode: z.number().int().optional(),
526
530
  signal: z.string().optional(),
531
+ timedOut: z.boolean(),
527
532
  wallTimeMs: z.number().nonnegative(),
528
533
  outputTruncated: z.boolean(),
529
534
  });
@@ -556,6 +561,7 @@ function processToolResponse(tool, workspaceId, snapshot, summary) {
556
561
  running: snapshot.running,
557
562
  exitCode: snapshot.exitCode,
558
563
  signal: snapshot.signal,
564
+ timedOut: snapshot.timedOut,
559
565
  wallTimeMs: snapshot.wallTimeMs,
560
566
  outputTruncated: snapshot.outputTruncated,
561
567
  },
@@ -613,9 +619,16 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
613
619
  .number()
614
620
  .int()
615
621
  .min(0)
616
- .max(30_000)
622
+ .max(300_000)
623
+ .optional()
624
+ .describe("Feedback window before returning a processId. Use 0 for immediate background handoff. Defaults to 10000ms."),
625
+ timeoutMs: z
626
+ .number()
627
+ .int()
628
+ .min(1)
629
+ .max(86_400_000)
617
630
  .optional()
618
- .describe("Milliseconds to wait before returning a running process. Defaults to 10000."),
631
+ .describe("Total execution timeout from process start. On expiry ForgeRelay terminates the process. Omit for no ForgeRelay execution deadline."),
619
632
  maxOutputTokens: z
620
633
  .number()
621
634
  .int()
@@ -627,49 +640,64 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
627
640
  outputSchema: processOutputSchema(),
628
641
  ...toolWidgetDescriptorMeta(config, "shell"),
629
642
  annotations: SHELL_TOOL_ANNOTATIONS,
630
- }, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }, extra) => {
643
+ }, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens }, extra) => {
631
644
  const workspace = workspaces.getWorkspace(workspaceId);
632
- return runToolWithHooks(hooks, {
633
- tool: "exec_command",
634
- invocation: workspaceHookInvocation(workspace),
635
- payload: { command: cmd, workingDirectory: workingDirectory ?? "." },
636
- operation: async () => {
637
- const startedAt = performance.now();
638
- const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
639
- await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
640
- const snapshot = await processSessions.start({
641
- workspaceId,
642
- command: cmd,
643
- cwd,
644
- workspaceRoot: workspace.root,
645
- tty,
646
- columns,
647
- rows,
648
- yieldTimeMs,
649
- maxOutputTokens,
650
- codexCi: true,
651
- });
652
- logToolCall(config, {
653
- tool: "exec_command",
654
- ...workspaceLogContext(workspace, extra.sessionId),
655
- workingDirectory: workingDirectory ?? ".",
656
- command: cmd,
657
- commandLength: cmd.length,
658
- exitCode: snapshot.exitCode,
659
- running: snapshot.running,
660
- processId: snapshot.processId,
661
- success: snapshot.running || snapshot.exitCode === 0,
662
- durationMs: Math.round(performance.now() - startedAt),
663
- });
664
- return processToolResponse("exec_command", workspaceId, snapshot, {
665
- command: cmd,
666
- workingDirectory: workingDirectory ?? ".",
667
- running: snapshot.running,
668
- exitCode: snapshot.exitCode,
669
- wallTimeMs: snapshot.wallTimeMs,
670
- });
671
- },
672
- });
645
+ let undeliveredProcessId;
646
+ try {
647
+ const result = await runToolWithHooks(hooks, {
648
+ signal: extra.signal,
649
+ tool: "exec_command",
650
+ invocation: workspaceHookInvocation(workspace),
651
+ payload: { command: cmd, workingDirectory: workingDirectory ?? "." },
652
+ operation: async () => {
653
+ const startedAt = performance.now();
654
+ const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
655
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
656
+ const snapshot = await processSessions.start({
657
+ workspaceId,
658
+ command: cmd,
659
+ cwd,
660
+ workspaceRoot: workspace.root,
661
+ tty,
662
+ columns,
663
+ rows,
664
+ yieldTimeMs,
665
+ timeoutMs,
666
+ maxOutputTokens,
667
+ codexCi: true,
668
+ signal: extra.signal,
669
+ });
670
+ undeliveredProcessId = snapshot.running ? snapshot.processId : undefined;
671
+ logToolCall(config, {
672
+ tool: "exec_command",
673
+ ...workspaceLogContext(workspace, extra.sessionId),
674
+ workingDirectory: workingDirectory ?? ".",
675
+ command: cmd,
676
+ commandLength: cmd.length,
677
+ exitCode: snapshot.exitCode,
678
+ running: snapshot.running,
679
+ processId: snapshot.processId,
680
+ success: snapshot.running || snapshot.exitCode === 0,
681
+ durationMs: Math.round(performance.now() - startedAt),
682
+ });
683
+ return processToolResponse("exec_command", workspaceId, snapshot, {
684
+ command: cmd,
685
+ workingDirectory: workingDirectory ?? ".",
686
+ running: snapshot.running,
687
+ exitCode: snapshot.exitCode,
688
+ wallTimeMs: snapshot.wallTimeMs,
689
+ });
690
+ },
691
+ });
692
+ extra.signal.throwIfAborted();
693
+ return result;
694
+ }
695
+ catch (error) {
696
+ if (undeliveredProcessId !== undefined) {
697
+ processSessions.discardUndelivered(workspaceId, undeliveredProcessId);
698
+ }
699
+ throw error;
700
+ }
673
701
  });
674
702
  }
675
703
  if (config.toolMode !== "codex")
@@ -706,6 +734,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
706
734
  const workspace = workspaces.getWorkspace(workspaceId);
707
735
  const resolvedProcessId = resolveProcessId(processId, sessionId);
708
736
  return runToolWithHooks(hooks, {
737
+ signal: extra.signal,
709
738
  tool: "write_stdin",
710
739
  invocation: workspaceHookInvocation(workspace),
711
740
  payload: {
@@ -724,6 +753,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
724
753
  rows,
725
754
  yieldTimeMs,
726
755
  maxOutputTokens,
756
+ signal: extra.signal,
727
757
  });
728
758
  logToolCall(config, {
729
759
  tool: "write_stdin",
@@ -1258,6 +1288,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1258
1288
  const workspace = workspaces.getWorkspace(workspaceId);
1259
1289
  let changedPaths = [];
1260
1290
  return runToolWithHooks(hooks, {
1291
+ signal: extra.signal,
1261
1292
  tool: toolNames.capability,
1262
1293
  invocation: workspaceHookInvocation(workspace),
1263
1294
  payload: { name, action },
@@ -1345,7 +1376,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1345
1376
  });
1346
1377
  registerAppTool(server, toolNames.closeWorkspace, {
1347
1378
  title: "Close workspace",
1348
- description: "Close one workspace after the user chooses cleanup. Checkout-backed workspaces release only the logical handle. Managed-worktree-backed workspaces finalize the existing safe worktree lifecycle, including hooks, commit/integration, and cleanup; provide commitMessage for that mode. Running or unconsumed processes prevent closure.",
1379
+ description: "Close one workspace after the user chooses cleanup. Checkout-backed workspaces release only the logical handle. Managed-worktree-backed workspaces finalize the existing safe worktree lifecycle, including hooks, commit/integration, and cleanup; provide commitMessage for that mode. Running processes prevent closure; completed background results are delivered with the close response when available.",
1349
1380
  inputSchema: {
1350
1381
  workspaceId: z.string().describe("Workspace identifier to close."),
1351
1382
  commitMessage: z
@@ -1370,6 +1401,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1370
1401
  }, async ({ workspaceId, commitMessage }, extra) => {
1371
1402
  const workspace = workspaces.getWorkspace(workspaceId);
1372
1403
  return runToolWithHooks(hooks, {
1404
+ signal: extra.signal,
1373
1405
  tool: toolNames.closeWorkspace,
1374
1406
  invocation: workspaceHookInvocation(workspace),
1375
1407
  payload: { workspaceId, commitMessage, mode: workspace.mode },
@@ -1386,7 +1418,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1386
1418
  const busyWorkspaceIds = physicalWorkspaceIds
1387
1419
  .filter((id) => processSessions.activeWorkspaceIds().has(id));
1388
1420
  if (busyWorkspaceIds.length > 0) {
1389
- throw new Error(`Cannot close this worktree-backed workspace while logical workspace processes are still running or awaiting completion delivery: ${busyWorkspaceIds.join(", ")}.`);
1421
+ throw new Error(`Cannot close this worktree-backed workspace while logical workspace processes are still running: ${busyWorkspaceIds.join(", ")}.`);
1390
1422
  }
1391
1423
  const startedAt = performance.now();
1392
1424
  const retirement = await codeIntelligence.retireWorkspaceRoot(workspace.root);
@@ -1434,7 +1466,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1434
1466
  throw new Error("close_workspace commitMessage is only valid for managed-worktree-backed workspaces.");
1435
1467
  }
1436
1468
  if (processSessions.activeWorkspaceIds().has(workspaceId)) {
1437
- throw new Error(`Workspace ${workspaceId} still owns a running process or an unconsumed process completion. Poll or consume it before closing this workspace.`);
1469
+ throw new Error(`Workspace ${workspaceId} still owns a running process. Poll, interrupt, or wait for it before closing this workspace.`);
1438
1470
  }
1439
1471
  workspaces.closeWorkspace(workspaceId);
1440
1472
  await reviewCheckpoints.releaseWorkspace(workspaceId);
@@ -1479,6 +1511,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1479
1511
  }, async ({ workspaceId, ...input }, extra) => {
1480
1512
  const workspace = workspaces.getWorkspace(workspaceId);
1481
1513
  return runToolWithHooks(hooks, {
1514
+ signal: extra.signal,
1482
1515
  tool: toolNames.read,
1483
1516
  invocation: workspaceHookInvocation(workspace),
1484
1517
  payload: { path: input.path, offset: input.offset, limit: input.limit },
@@ -1565,6 +1598,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1565
1598
  }, async ({ workspaceId, ...input }, extra) => {
1566
1599
  const workspace = workspaces.getWorkspace(workspaceId);
1567
1600
  return runToolWithHooks(hooks, {
1601
+ signal: extra.signal,
1568
1602
  tool: toolNames.write,
1569
1603
  invocation: workspaceHookInvocation(workspace),
1570
1604
  payload: { path: input.path },
@@ -1648,6 +1682,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1648
1682
  }, async ({ workspaceId, ...input }, extra) => {
1649
1683
  const workspace = workspaces.getWorkspace(workspaceId);
1650
1684
  return runToolWithHooks(hooks, {
1685
+ signal: extra.signal,
1651
1686
  tool: toolNames.edit,
1652
1687
  invocation: workspaceHookInvocation(workspace),
1653
1688
  payload: { path: input.path, editCount: input.edits.length },
@@ -1724,6 +1759,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1724
1759
  }, async ({ workspaceId, path, newPath }, extra) => {
1725
1760
  const workspace = workspaces.getWorkspace(workspaceId);
1726
1761
  return runToolWithHooks(hooks, {
1762
+ signal: extra.signal,
1727
1763
  tool: toolNames.rename,
1728
1764
  invocation: workspaceHookInvocation(workspace),
1729
1765
  payload: { path, newPath, paths: [path, newPath] },
@@ -1796,6 +1832,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1796
1832
  }, async ({ workspaceId, path, recursive }, extra) => {
1797
1833
  const workspace = workspaces.getWorkspace(workspaceId);
1798
1834
  return runToolWithHooks(hooks, {
1835
+ signal: extra.signal,
1799
1836
  tool: toolNames.delete,
1800
1837
  invocation: workspaceHookInvocation(workspace),
1801
1838
  payload: { path, recursive: recursive ?? false },
@@ -1876,6 +1913,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1876
1913
  }, async ({ workspaceId, patch }, extra) => {
1877
1914
  const workspace = workspaces.getWorkspace(workspaceId);
1878
1915
  return runToolWithHooks(hooks, {
1916
+ signal: extra.signal,
1879
1917
  tool: "apply_patch",
1880
1918
  invocation: workspaceHookInvocation(workspace),
1881
1919
  payload: { patchBytes: Buffer.byteLength(patch) },
@@ -1982,7 +2020,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1982
2020
  .min(0)
1983
2021
  .max(300_000)
1984
2022
  .optional()
1985
- .describe("Milliseconds to wait before returning. Run preserves the existing 300000ms default; process polling defaults to 5000ms and interaction to 250ms."),
2023
+ .describe("Feedback window before returning. For action=run, use 0 for immediate background handoff; otherwise defaults to 10000ms. For action=process, polling defaults to 5000ms and interaction to 250ms."),
2024
+ timeoutMs: z
2025
+ .number()
2026
+ .int()
2027
+ .min(1)
2028
+ .max(86_400_000)
2029
+ .optional()
2030
+ .describe("For action=run, total execution timeout from process start. On expiry ForgeRelay terminates the process. Omit for no ForgeRelay execution deadline."),
1986
2031
  maxOutputTokens: z
1987
2032
  .number()
1988
2033
  .int()
@@ -1994,7 +2039,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1994
2039
  outputSchema: processOutputSchema(),
1995
2040
  ...toolWidgetDescriptorMeta(config, "shell"),
1996
2041
  annotations: SHELL_TOOL_ANNOTATIONS,
1997
- }, async ({ workspaceId, action = "run", command, processId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens, }, extra) => {
2042
+ }, async ({ workspaceId, action = "run", command, processId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens, }, extra) => {
1998
2043
  const workspace = workspaces.getWorkspace(workspaceId);
1999
2044
  if (action === "run") {
2000
2045
  if (!command)
@@ -2002,58 +2047,73 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2002
2047
  if (processId !== undefined || input !== undefined || interrupt !== undefined) {
2003
2048
  throw new Error("bash action=run does not accept processId, input, or interrupt.");
2004
2049
  }
2005
- return runToolWithHooks(hooks, {
2006
- tool: toolNames.shell,
2007
- invocation: workspaceHookInvocation(workspace),
2008
- payload: {
2009
- action,
2010
- command,
2011
- workingDirectory: workingDirectory ?? ".",
2012
- },
2013
- isFailure: toolResultIsError,
2014
- operation: async () => {
2015
- const startedAt = performance.now();
2016
- const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
2017
- await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
2018
- const snapshot = await processSessions.start({
2019
- workspaceId,
2020
- command,
2021
- cwd,
2022
- workspaceRoot: workspace.root,
2023
- tty,
2024
- columns,
2025
- rows,
2026
- yieldTimeMs: yieldTimeMs ?? 300_000,
2027
- maxOutputTokens,
2028
- });
2029
- logToolCall(config, {
2030
- tool: toolNames.shell,
2031
- ...workspaceLogContext(workspace, extra.sessionId),
2032
- workingDirectory: workingDirectory ?? ".",
2033
- command,
2034
- commandLength: command.length,
2035
- exitCode: snapshot.exitCode,
2036
- running: snapshot.running,
2037
- processId: snapshot.processId,
2038
- success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
2039
- durationMs: Math.round(performance.now() - startedAt),
2040
- });
2041
- const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
2050
+ let undeliveredProcessId;
2051
+ try {
2052
+ const result = await runToolWithHooks(hooks, {
2053
+ signal: extra.signal,
2054
+ tool: toolNames.shell,
2055
+ invocation: workspaceHookInvocation(workspace),
2056
+ payload: {
2042
2057
  action,
2043
2058
  command,
2044
2059
  workingDirectory: workingDirectory ?? ".",
2045
- running: snapshot.running,
2046
- exitCode: snapshot.exitCode,
2047
- wallTimeMs: snapshot.wallTimeMs,
2048
- });
2049
- return !snapshot.running && (snapshot.signal || snapshot.exitCode !== 0)
2050
- ? { ...response, isError: true }
2051
- : response;
2052
- },
2053
- });
2060
+ },
2061
+ isFailure: toolResultIsError,
2062
+ operation: async () => {
2063
+ const startedAt = performance.now();
2064
+ const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
2065
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
2066
+ const snapshot = await processSessions.start({
2067
+ workspaceId,
2068
+ command,
2069
+ cwd,
2070
+ workspaceRoot: workspace.root,
2071
+ tty,
2072
+ columns,
2073
+ rows,
2074
+ yieldTimeMs,
2075
+ timeoutMs,
2076
+ maxOutputTokens,
2077
+ signal: extra.signal,
2078
+ });
2079
+ undeliveredProcessId = snapshot.running ? snapshot.processId : undefined;
2080
+ logToolCall(config, {
2081
+ tool: toolNames.shell,
2082
+ ...workspaceLogContext(workspace, extra.sessionId),
2083
+ workingDirectory: workingDirectory ?? ".",
2084
+ command,
2085
+ commandLength: command.length,
2086
+ exitCode: snapshot.exitCode,
2087
+ running: snapshot.running,
2088
+ processId: snapshot.processId,
2089
+ success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
2090
+ durationMs: Math.round(performance.now() - startedAt),
2091
+ });
2092
+ const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
2093
+ action,
2094
+ command,
2095
+ workingDirectory: workingDirectory ?? ".",
2096
+ running: snapshot.running,
2097
+ exitCode: snapshot.exitCode,
2098
+ wallTimeMs: snapshot.wallTimeMs,
2099
+ });
2100
+ return !snapshot.running && (snapshot.signal || snapshot.exitCode !== 0)
2101
+ ? { ...response, isError: true }
2102
+ : response;
2103
+ },
2104
+ });
2105
+ extra.signal.throwIfAborted();
2106
+ return result;
2107
+ }
2108
+ catch (error) {
2109
+ if (undeliveredProcessId !== undefined) {
2110
+ processSessions.discardUndelivered(workspaceId, undeliveredProcessId);
2111
+ }
2112
+ throw error;
2113
+ }
2054
2114
  }
2055
- if (command !== undefined || workingDirectory !== undefined || tty !== undefined) {
2056
- throw new Error("bash action=process does not accept command, workingDirectory, or tty.");
2115
+ if (command !== undefined || workingDirectory !== undefined || tty !== undefined || timeoutMs !== undefined) {
2116
+ throw new Error("bash action=process does not accept command, workingDirectory, tty, or timeoutMs.");
2057
2117
  }
2058
2118
  if (processId === undefined)
2059
2119
  throw new Error("bash action=process requires processId.");
@@ -2061,6 +2121,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2061
2121
  throw new Error("bash action=process cannot combine interrupt with input.");
2062
2122
  }
2063
2123
  return runToolWithHooks(hooks, {
2124
+ signal: extra.signal,
2064
2125
  tool: toolNames.shell,
2065
2126
  invocation: workspaceHookInvocation(workspace),
2066
2127
  payload: {
@@ -2082,6 +2143,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2082
2143
  rows,
2083
2144
  yieldTimeMs,
2084
2145
  maxOutputTokens,
2146
+ signal: extra.signal,
2085
2147
  });
2086
2148
  logToolCall(config, {
2087
2149
  tool: toolNames.shell,
@@ -264,11 +264,16 @@ capability
264
264
 
265
265
  In minimal mode, normal shell inspection commands such as `rg`, `find`, and `ls`
266
266
  can be used rather than dedicated MCP search tools. `bash(action="run")` (or plain
267
- `bash`, since `run` is the default) waits in the foreground for at most 300 seconds.
268
- If the command is still running, ForgeRelay returns a canonical `processId` without
269
- killing it. Reuse `bash(action="process", processId=...)` to poll/wait, send input,
270
- resize a PTY, or interrupt the existing process; or continue other work and consume
271
- the one-shot completion notice from a later result in the same workspace.
267
+ `bash`, since `run` is the default) separates feedback from execution lifetime:
268
+ `yieldTimeMs` controls how long to wait before returning a canonical `processId`
269
+ (default 10 seconds; use `0` when intentionally starting background work), while
270
+ optional `timeoutMs` independently caps total process runtime. A routine command can
271
+ use a longer feedback window such as 60 seconds when that remains below the Host
272
+ request deadline. Reuse `bash(action="process", processId=...)` to poll incremental
273
+ output/wait, send input, resize a PTY, or interrupt the existing process; or continue
274
+ other work and consume the one-shot completion notice from a later result in the same
275
+ workspace. Full completed output is retained for five minutes and then compacted to a
276
+ bounded completion record that remains deliverable for up to 24 hours.
272
277
 
273
278
  `FORGERELAY_TOOL_MODE=full` is retained as a compatibility value and exposes the
274
279
  same canonical 9-tool surface as `minimal`; use `bash` for search and directory
@@ -305,16 +305,25 @@ context-delivery checkpoints, and other semantic state transitions remain immedi
305
305
  persistent writes. A hard process crash may therefore lose only the most recent
306
306
  activity timestamp window, not the existence or closed/open state of a workspace.
307
307
 
308
- Regular `bash` has no execution-timeout input. `action="run"` (the default) waits
309
- in the foreground for at most 300 seconds; if the process is still alive, the
310
- result contains `running: true` and a canonical `processId`. Reuse the same `bash`
311
- with `action="process"` to poll/wait, send `input`, resize a PTY, or set
312
- `interrupt:true`; each wait can be up to 300 seconds. ForgeRelay does not kill a
313
- process merely because a wait window expires. Completed background processes are
314
- delivered once with a later tool result for the same logical workspace ID. An
315
- unconsumed completed-process notice is retained for at most five minutes, and
316
- ForgeRelay also bounds active and completed process counts so repeated background
317
- commands cannot grow server memory without limit.
308
+ Regular `bash` separates the **feedback window** from the **execution deadline**. For
309
+ `action="run"`, `yieldTimeMs` controls how long the current MCP request waits before
310
+ returning `running: true` plus a canonical `processId`; it defaults to 10 seconds and
311
+ `0` is the intentional-background form. `timeoutMs` is independent: when explicitly
312
+ set it limits total runtime from process start and ForgeRelay terminates the process
313
+ on expiry; when omitted ForgeRelay imposes no execution deadline. Reuse the same
314
+ `bash` with `action="process"` to poll incremental output, wait, send `input`, resize
315
+ a PTY, or set `interrupt:true`; each poll wait can be up to 300 seconds and does not
316
+ kill the process merely because the feedback window expires. Host cancellation of
317
+ the initial run request terminates a not-yet-handed-off process so ForgeRelay does
318
+ not leave an orphan process whose `processId` the Agent never received.
319
+
320
+ Completed background processes are delivered once with a later tool result for the
321
+ same logical workspace ID. Full buffered completion output is retained for five
322
+ minutes; after that ForgeRelay compacts the completion to a bounded head/tail record
323
+ and keeps it deliverable for up to 24 hours, still subject to the global completed
324
+ process count bound. Completed processes no longer prevent `close_workspace`; the
325
+ close response itself delivers any available completion notice. Running processes
326
+ continue to block close until they finish or are interrupted.
318
327
 
319
328
  Codex mode retains `write_stdin` only as an experimental compatibility adapter;
320
329
  regular Agent workflows should use the single `bash` process lifecycle.
@@ -358,13 +367,13 @@ Hooks v1 是自动生命周期规则。规则由用户或 Agent 主动写入;
358
367
  "tool": "bash",
359
368
  "commandRegex": "git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+"
360
369
  },
361
- "command": "npm run release:verify",
362
- "timeoutSeconds": 300,
370
+ "command": "node scripts/release-proof.mjs check-hook",
371
+ "timeoutSeconds": 30,
363
372
  "report": true
364
373
  }
365
374
  ```
366
375
 
367
- 这个例子可以保存为 `.forgerelay/hooks/release-tag-local-ci.json`。Agent 通过 ForgeRelay `bash` 请求推送稳定版本 tag 时,Hook 先跑本地发布检查;成功后才执行原始 `git push`,失败则直接阻断并把 `release-tag-local-ci` 的失败报告返回给 Agent。
376
+ 这个例子可以保存为 `.forgerelay/hooks/release-tag-local-ci.json`。耗时的 `npm run release:verify` 应在已提交的 release-ready HEAD 上提前运行,并在 `.git/forgerelay/` 写入 release proof。Agent 之后通过 ForgeRelay `bash` 请求推送稳定版本 tag 时,Hook 只快速校验 proof、当前 HEAD/package version、clean working tree(含 untracked)与本地 tag 指向;全部一致才执行原始 `git push`。因此发布 gate 不再依赖一个持续数分钟的单次 MCP request,同时任何验证后的代码变化都会使 proof 失效并阻断推送。
368
377
 
369
378
  独立 Hook 文件支持这些顶层字段:
370
379
 
@@ -423,7 +432,7 @@ Matcher 匹配 ForgeRelay 收到的那次 tool request,不会窥探该命令
423
432
  | `SubagentStart` | 本地 subagent worker 进入执行时触发。 |
424
433
  | `SubagentStop` | subagent 完成或进入 error 状态时触发。 |
425
434
 
426
- `BeforeTool` 与 `BeforeWorktreeClose` 是 blocking 事件。其他事件是 observational:失败会被记录并报告,但不会回滚已经完成的文件、Git、进程或网络副作用。Blocking 同样不是事务;Hook 命令自己已经产生的副作用不会因 exit code 非零而撤销。
435
+ `BeforeTool` 与 `BeforeWorktreeClose` 是 blocking 事件。其他事件是 observational:失败会被记录并报告,但不会回滚已经完成的文件、Git、进程或网络副作用。Blocking 同样不是事务;Hook 命令自己已经产生的副作用不会因 exit code 非零而撤销。Host 在 blocking Hook 仍运行时取消 MCP request,会终止该 Hook 并阻止原始 tool operation 开始,因此不会出现 Host 已放弃请求后 Hook 又放行后续原始副作用的情况。
427
436
 
428
437
  ### Agent 可见报告
429
438
 
@@ -431,7 +440,7 @@ Matcher 匹配 ForgeRelay 收到的那次 tool request,不会窥探该命令
431
440
 
432
441
  ```text
433
442
  Hook results:
434
- ✓ release-tag-local-ci (BeforeTool, project) passed in 38124ms
443
+ ✓ release-tag-local-ci (BeforeTool, project) passed in 42ms
435
444
  ```
436
445
 
437
446
  阻断失败会明确显示 `failed`。ForgeRelay 的 server instructions 要求 Agent 在出现 Hook results 时,向用户说明有意义的 Hook 是否通过或阻断了操作。异步 subagent 的 `SubagentStart` / `SubagentStop` 报告会随 session 持久化,并由 `forgerelay agents show` 展示。
package/docs/roadmap.md CHANGED
@@ -238,7 +238,10 @@ and verify publication before work begins on the next boundary:
238
238
  - **0.4.5** — cancellation, deadlines, crash recovery/config invalidation, concurrency,
239
239
  and full Language-service resource/lifecycle hardening;
240
240
  - **0.4.6** — optional real-server interoperability, cross-platform checks, fresh Host
241
- acceptance, documentation, and final LSP v1 closure.
241
+ acceptance, documentation, and final LSP v1 closure;
242
+ - **0.4.7** — post-acceptance Hook/process lifecycle hardening: independent bash feedback
243
+ and execution deadlines, Host cancellation propagation, longer bounded completion delivery,
244
+ and proof-based release Hooks that do not hold a multi-minute MCP request open.
242
245
 
243
246
  The shipping 0.4 LSP v1 contract keeps the canonical nine Core MCP tools unchanged
244
247
  and exposes semantic operations only through `code.intelligence`. Deterministic
@@ -85,8 +85,10 @@ Run the full local release gate with:
85
85
  npm run release:verify
86
86
  ```
87
87
 
88
- `release:verify` checks the current development runtime and then runs a focused
89
- `release:parity` gate in an isolated Node 22.19.0 sandbox. The parity sandbox
88
+ `release:verify` checks the current committed release-ready runtime and then runs a focused
89
+ `release:parity` gate in an isolated Node 22.19.0 sandbox. After every check passes it
90
+ records a local release proof under `.git/forgerelay/`, binding that verification to
91
+ current HEAD and the package version. The parity sandbox
90
92
  performs its own `npm ci` so native addons use the same Node ABI as cloud CI,
91
93
  then reruns the LSP/release tests most sensitive to event-loop timing, process
92
94
  lifecycle, path canonicalization, executable discovery, and cleanup behavior.
@@ -169,11 +171,11 @@ npm publishing token.
169
171
  2. Run `npm run release:check`.
170
172
  3. Run the appropriate `release:patch`, `release:minor`, or `release:major`
171
173
  command.
172
- 4. Review the generated version and changelog diff.
173
- 5. Run `npm run release:verify` locally. This full local gate includes the isolated
174
- Node 22.19.0 parity sandbox and is a release-time operation; ordinary development
174
+ 4. Review the generated version and changelog diff, then commit the release-ready code and metadata.
175
+ 5. Run `npm run release:verify` locally on that clean committed HEAD. This full local gate includes the isolated
176
+ Node 22.19.0 parity sandbox and records the local release proof consumed by the tag-push Hook; ordinary development
175
177
  pushes do not need to run the full release gate.
176
- 6. Commit the release-ready code and metadata and push `main`.
178
+ 6. Push the verified `main` commit without changing it afterward.
177
179
  7. Create the exact version tag, for example:
178
180
 
179
181
  ```bash
@@ -183,7 +185,7 @@ npm publishing token.
183
185
 
184
186
  The tag push is the publication action. The release workflow publishes npm only after cloud CI passes, then extracts the matching `CHANGELOG.md` release section as the GitHub Release body. Keep `Unreleased` user-facing and structured (`Added`, `Changed`, `Fixed`, `Security`) because those notes are what users see on the Release page.
185
187
 
186
- Project release Hooks match the stable tag-push command as a substring of the ForgeRelay shell request. A compound command is allowed: when `commandRegex` matches `git push origin vX.Y.Z`, the Hook receives that matched command as `FORGERELAY_HOOK_PAYLOAD.command` and retains the complete shell request as `originalCommand` when they differ.
188
+ Project release Hooks match the stable tag-push command as a substring of the ForgeRelay shell request. A compound command is allowed: when `commandRegex` matches `git push origin vX.Y.Z`, the Hook receives that matched command as `FORGERELAY_HOOK_PAYLOAD.command` and retains the complete shell request as `originalCommand` when they differ. The release Hook does **not** rerun the multi-minute local gate inside the tag-push MCP request. Instead it quickly verifies the proof written by `release:verify`, requires a clean working tree including untracked files, requires the proof HEAD/package version to equal the current release state, and requires the local tag to resolve to that same verified HEAD. Any change after verification invalidates the proof and blocks the push until `release:verify` is rerun.
187
189
 
188
190
  ## Attribution guardrails
189
191
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
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",
@@ -44,7 +44,7 @@
44
44
  "release:parity": "node scripts/release-parity.mjs",
45
45
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
46
46
  "start": "node dist/cli.js serve",
47
- "test": "tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
47
+ "test": "node --test scripts/release-proof.test.mjs && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
48
48
  "typecheck": "tsc -p tsconfig.json --noEmit",
49
49
  "release:check": "node scripts/release-version.mjs check",
50
50
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -52,7 +52,7 @@
52
52
  "release:patch": "node scripts/release-version.mjs next patch",
53
53
  "release:minor": "node scripts/release-version.mjs next minor",
54
54
  "release:major": "node scripts/release-version.mjs next major",
55
- "release:verify": "npm run release:check && npm run typecheck && npm test && npm run build && npm run lsp:interop && npm run release:parity"
55
+ "release:verify": "npm run release:check && npm run typecheck && npm test && npm run build && npm run lsp:interop && npm run release:parity && node scripts/release-proof.mjs write"
56
56
  },
57
57
  "keywords": [
58
58
  "mcp",
@@ -164,6 +164,7 @@ try {
164
164
  assert.equal(bashTool?.inputSchema?.properties?.timeout, undefined);
165
165
  assert.match(bashTool?.description ?? "", /action=process/);
166
166
  assert.equal(bashTool?.inputSchema?.properties?.yieldTimeMs?.maximum, 300000);
167
+ assert.equal(bashTool?.inputSchema?.properties?.timeoutMs?.maximum, 86400000);
167
168
  assert.match(bashTool?.inputSchema?.properties?.processId?.description ?? "", /action=process/);
168
169
  assert.match(bashTool?.inputSchema?.properties?.interrupt?.description ?? "", /SIGINT/);
169
170
  const openWorkspaceTool = tools.find((tool) => tool.name === "open_workspace");