@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/CHANGELOG.md +18 -0
- package/README.md +8 -7
- package/dist/hooks.js +43 -14
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/process-sessions.js +119 -14
- package/dist/server.js +165 -103
- package/docs/chatgpt-coding-workflow.md +10 -5
- package/docs/configuration.md +24 -15
- package/docs/roadmap.md +4 -1
- package/docs/versioning.md +9 -7
- package/package.json +3 -3
- package/scripts/debug/accept.mjs +1 -0
- package/scripts/release-proof.mjs +139 -0
- package/scripts/release-proof.test.mjs +104 -0
- package/scripts/release-version.mjs +1 -1
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.
|
|
473
|
-
?
|
|
474
|
-
:
|
|
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.
|
|
479
|
-
? `Background process ${snapshot.processId}
|
|
480
|
-
:
|
|
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(
|
|
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("
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
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
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
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
|
|
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)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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`
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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": "
|
|
362
|
-
"timeoutSeconds":
|
|
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
|
|
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
|
|
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
|
package/docs/versioning.md
CHANGED
|
@@ -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
|
|
89
|
-
`release:parity` gate in an isolated Node 22.19.0 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
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -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");
|