@akira-tl/forgerelay 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -15,6 +15,7 @@ import * as z from "zod/v4";
15
15
  import { applyPatch } from "./apply-patch.js";
16
16
  import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js";
17
17
  import { loadConfig } from "./config.js";
18
+ import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
18
19
  import { buildServerInstructions, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
19
20
  import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
20
21
  import { logEvent, requestIp, requestPath, commandPreview, sessionIdPrefix, } from "./logger.js";
@@ -33,6 +34,7 @@ import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvail
33
34
  // MCP clients can reconnect without closing the previous transport. Bound stale
34
35
  // session retention so abandoned MCP servers do not accumulate for the life of the process.
35
36
  const MCP_SESSION_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
37
+ const FORGERELAY_VERSION = readForgeRelayVersion();
36
38
  const MCP_SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
37
39
  const WORKSPACE_APP_URI = "ui://forgerelay/workspace-app.html";
38
40
  const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html";
@@ -313,6 +315,13 @@ function processOutputSchema() {
313
315
  outputTruncated: z.boolean(),
314
316
  });
315
317
  }
318
+ function readForgeRelayVersion() {
319
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
320
+ if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
321
+ throw new Error("Unable to read ForgeRelay package version.");
322
+ }
323
+ return packageJson.version;
324
+ }
316
325
  function processToolResponse(tool, workspaceId, snapshot, summary) {
317
326
  const result = processResult(snapshot);
318
327
  const content = [textBlock(result)];
@@ -338,7 +347,18 @@ function processToolResponse(tool, workspaceId, snapshot, summary) {
338
347
  },
339
348
  };
340
349
  }
341
- function registerCodexProcessTools(server, config, workspaces, processSessions) {
350
+ function workspaceHookInvocation(workspace) {
351
+ return {
352
+ workspaceId: workspace.id,
353
+ workspaceRoot: workspace.root,
354
+ workspaceMode: workspace.mode,
355
+ sourceRoot: workspace.sourceRoot,
356
+ };
357
+ }
358
+ function toolResultIsError(result) {
359
+ return typeof result === "object" && result !== null && result.isError === true;
360
+ }
361
+ function registerCodexProcessTools(server, config, workspaces, processSessions, hooks) {
342
362
  registerAppTool(server, "exec_command", {
343
363
  title: "Execute command",
344
364
  description: "Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a sessionId for write_stdin. Use this for file inspection, tests, builds, package scripts, and long-running processes. Call open_workspace first and pass workspaceId.",
@@ -374,35 +394,42 @@ function registerCodexProcessTools(server, config, workspaces, processSessions)
374
394
  ...toolWidgetDescriptorMeta(config, "shell"),
375
395
  annotations: SHELL_TOOL_ANNOTATIONS,
376
396
  }, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }) => {
377
- const startedAt = performance.now();
378
397
  const workspace = workspaces.getWorkspace(workspaceId);
379
- const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
380
- const snapshot = await processSessions.start({
381
- workspaceId,
382
- command: cmd,
383
- cwd,
384
- workspaceRoot: workspace.root,
385
- tty,
386
- columns,
387
- rows,
388
- yieldTimeMs,
389
- maxOutputTokens,
390
- });
391
- logToolCall(config, {
398
+ return runToolWithHooks(hooks, {
392
399
  tool: "exec_command",
393
- workspaceId,
394
- workingDirectory: workingDirectory ?? ".",
395
- command: cmd,
396
- commandLength: cmd.length,
397
- success: true,
398
- durationMs: Math.round(performance.now() - startedAt),
399
- });
400
- return processToolResponse("exec_command", workspaceId, snapshot, {
401
- command: cmd,
402
- workingDirectory: workingDirectory ?? ".",
403
- running: snapshot.running,
404
- exitCode: snapshot.exitCode,
405
- wallTimeMs: snapshot.wallTimeMs,
400
+ invocation: workspaceHookInvocation(workspace),
401
+ payload: { command: cmd, workingDirectory: workingDirectory ?? "." },
402
+ operation: async () => {
403
+ const startedAt = performance.now();
404
+ const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
405
+ const snapshot = await processSessions.start({
406
+ workspaceId,
407
+ command: cmd,
408
+ cwd,
409
+ workspaceRoot: workspace.root,
410
+ tty,
411
+ columns,
412
+ rows,
413
+ yieldTimeMs,
414
+ maxOutputTokens,
415
+ });
416
+ logToolCall(config, {
417
+ tool: "exec_command",
418
+ workspaceId,
419
+ workingDirectory: workingDirectory ?? ".",
420
+ command: cmd,
421
+ commandLength: cmd.length,
422
+ success: true,
423
+ durationMs: Math.round(performance.now() - startedAt),
424
+ });
425
+ return processToolResponse("exec_command", workspaceId, snapshot, {
426
+ command: cmd,
427
+ workingDirectory: workingDirectory ?? ".",
428
+ running: snapshot.running,
429
+ exitCode: snapshot.exitCode,
430
+ wallTimeMs: snapshot.wallTimeMs,
431
+ });
432
+ },
406
433
  });
407
434
  });
408
435
  registerAppTool(server, "write_stdin", {
@@ -433,38 +460,51 @@ function registerCodexProcessTools(server, config, workspaces, processSessions)
433
460
  ...toolWidgetDescriptorMeta(config, "shell"),
434
461
  annotations: SHELL_TOOL_ANNOTATIONS,
435
462
  }, async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }) => {
436
- const startedAt = performance.now();
437
- workspaces.getWorkspace(workspaceId);
438
- const snapshot = await processSessions.write({
439
- workspaceId,
440
- sessionId,
441
- chars,
442
- columns,
443
- rows,
444
- yieldTimeMs,
445
- maxOutputTokens,
446
- });
447
- logToolCall(config, {
463
+ const workspace = workspaces.getWorkspace(workspaceId);
464
+ return runToolWithHooks(hooks, {
448
465
  tool: "write_stdin",
449
- workspaceId,
450
- success: true,
451
- durationMs: Math.round(performance.now() - startedAt),
452
- });
453
- return processToolResponse("write_stdin", workspaceId, snapshot, {
454
- sessionId,
455
- charactersWritten: chars?.length ?? 0,
456
- running: snapshot.running,
457
- exitCode: snapshot.exitCode,
458
- wallTimeMs: snapshot.wallTimeMs,
466
+ invocation: workspaceHookInvocation(workspace),
467
+ payload: {
468
+ sessionId,
469
+ charactersWritten: chars?.length ?? 0,
470
+ columns,
471
+ rows,
472
+ },
473
+ operation: async () => {
474
+ const startedAt = performance.now();
475
+ const snapshot = await processSessions.write({
476
+ workspaceId,
477
+ sessionId,
478
+ chars,
479
+ columns,
480
+ rows,
481
+ yieldTimeMs,
482
+ maxOutputTokens,
483
+ });
484
+ logToolCall(config, {
485
+ tool: "write_stdin",
486
+ workspaceId,
487
+ success: true,
488
+ durationMs: Math.round(performance.now() - startedAt),
489
+ });
490
+ return processToolResponse("write_stdin", workspaceId, snapshot, {
491
+ sessionId,
492
+ charactersWritten: chars?.length ?? 0,
493
+ running: snapshot.running,
494
+ exitCode: snapshot.exitCode,
495
+ wallTimeMs: snapshot.wallTimeMs,
496
+ });
497
+ },
459
498
  });
460
499
  });
461
500
  }
462
501
  export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
463
502
  const toolDescriptions = buildToolDescriptions(config);
503
+ const hooks = new HookRunner(config.hooks, config.logging);
464
504
  const server = new McpServer({
465
505
  name: "forgerelay",
466
506
  title: "ForgeRelay",
467
- version: "0.1.0",
507
+ version: FORGERELAY_VERSION,
468
508
  description: "Secure local coding workspace for MCP clients. Provides workspace-scoped file, search, edit, write, and shell tools.",
469
509
  }, {
470
510
  instructions: buildServerInstructions(config, {
@@ -559,7 +599,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
559
599
  },
560
600
  }, async ({ path, mode, baseRef, newWorktree }, { _meta }) => {
561
601
  const startedAt = performance.now();
562
- const { workspace, agentsFiles, availableAgentsFiles, workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace({ path, mode, baseRef, newWorktree }, { conversationScopeId: openAiConversationScopeId(_meta) });
602
+ const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace({ path, mode, baseRef, newWorktree }, { conversationScopeId: openAiConversationScopeId(_meta) });
563
603
  const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
564
604
  if (config.widgets === "changes") {
565
605
  await reviewCheckpoints.initializeWorkspace({
@@ -657,7 +697,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
657
697
  success: true,
658
698
  durationMs: Math.round(performance.now() - startedAt),
659
699
  });
660
- return {
700
+ return attachHookReports({
661
701
  content: resultContent,
662
702
  _meta: {
663
703
  tool: "open_workspace",
@@ -706,7 +746,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
706
746
  : {}),
707
747
  instruction,
708
748
  },
709
- };
749
+ }, hookReports);
710
750
  });
711
751
  registerAppTool(server, toolNames.closeWorktree, {
712
752
  title: "Close worktree",
@@ -733,38 +773,47 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
733
773
  _meta: {},
734
774
  annotations: WRITE_TOOL_ANNOTATIONS,
735
775
  }, async ({ workspaceId, commitMessage }) => {
736
- const startedAt = performance.now();
737
- const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
738
- const result = [
739
- `Closed managed worktree ${workspaceId}.`,
740
- `Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
741
- `Source checkout: ${closed.sourceRoot}`,
742
- `Commit: ${closed.commitSha}`,
743
- closed.cleanupWarning
744
- ? `Cleanup warning: ${closed.cleanupWarning}`
745
- : "The worktree directory and managed branch were removed.",
746
- ].join("\n");
747
- logToolCall(config, {
776
+ const workspace = workspaces.getWorkspace(workspaceId);
777
+ return runToolWithHooks(hooks, {
748
778
  tool: toolNames.closeWorktree,
749
- workspaceId,
750
- path: closed.sourceRoot,
751
- success: true,
752
- durationMs: Math.round(performance.now() - startedAt),
753
- });
754
- return {
755
- content: [{ type: "text", text: result }],
756
- structuredContent: {
757
- result,
758
- workspaceId,
759
- sourceRoot: closed.sourceRoot,
760
- branch: closed.branch,
761
- targetBranch: closed.targetBranch,
762
- commitSha: closed.commitSha,
763
- mergedSha: closed.mergedSha,
764
- committed: closed.committed,
765
- cleanupWarning: closed.cleanupWarning,
779
+ invocation: workspaceHookInvocation(workspace),
780
+ payload: { commitMessage },
781
+ afterCwd: (response) => response.structuredContent.sourceRoot,
782
+ operation: async () => {
783
+ const startedAt = performance.now();
784
+ const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
785
+ const result = [
786
+ `Closed managed worktree ${workspaceId}.`,
787
+ `Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
788
+ `Source checkout: ${closed.sourceRoot}`,
789
+ `Commit: ${closed.commitSha}`,
790
+ closed.cleanupWarning
791
+ ? `Cleanup warning: ${closed.cleanupWarning}`
792
+ : "The worktree directory and managed branch were removed.",
793
+ ].join("\n");
794
+ logToolCall(config, {
795
+ tool: toolNames.closeWorktree,
796
+ workspaceId,
797
+ path: closed.sourceRoot,
798
+ success: true,
799
+ durationMs: Math.round(performance.now() - startedAt),
800
+ });
801
+ return attachHookReports({
802
+ content: [{ type: "text", text: result }],
803
+ structuredContent: {
804
+ result,
805
+ workspaceId,
806
+ sourceRoot: closed.sourceRoot,
807
+ branch: closed.branch,
808
+ targetBranch: closed.targetBranch,
809
+ commitSha: closed.commitSha,
810
+ mergedSha: closed.mergedSha,
811
+ committed: closed.committed,
812
+ cleanupWarning: closed.cleanupWarning,
813
+ },
814
+ }, closed.hookReports);
766
815
  },
767
- };
816
+ });
768
817
  });
769
818
  registerAppTool(server, toolNames.read, {
770
819
  title: "Read file",
@@ -795,50 +844,58 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
795
844
  ...toolWidgetDescriptorMeta(config, "read"),
796
845
  annotations: { readOnlyHint: true },
797
846
  }, async ({ workspaceId, ...input }) => {
798
- const startedAt = performance.now();
799
847
  const workspace = workspaces.getWorkspace(workspaceId);
800
- const readPath = workspaces.resolveReadPath(workspace, input.path);
801
- const response = await readFileTool({ ...input, path: readPath.absolutePath }, {
802
- cwd: workspace.root,
803
- root: workspace.root,
804
- readRoots: readPath.readRoots,
805
- });
806
- if (response.isError) {
807
- logFailedToolResponse(config, {
808
- tool: toolNames.read,
809
- workspaceId,
810
- path: input.path,
811
- }, response.content, startedAt);
812
- return response;
813
- }
814
- workspaces.markReadPathLoaded(workspace, readPath);
815
- const summary = {
816
- ...textSummary(response.content),
817
- offset: input.offset ?? 1,
818
- limited: input.limit !== undefined,
819
- };
820
- logToolCall(config, {
848
+ return runToolWithHooks(hooks, {
821
849
  tool: toolNames.read,
822
- workspaceId,
823
- path: input.path,
824
- success: true,
825
- durationMs: Math.round(performance.now() - startedAt),
826
- });
827
- return {
828
- ...response,
829
- _meta: {
830
- tool: toolNames.read,
831
- card: {
850
+ invocation: workspaceHookInvocation(workspace),
851
+ payload: { path: input.path, offset: input.offset, limit: input.limit },
852
+ isFailure: toolResultIsError,
853
+ operation: async () => {
854
+ const startedAt = performance.now();
855
+ const readPath = workspaces.resolveReadPath(workspace, input.path);
856
+ const response = await readFileTool({ ...input, path: readPath.absolutePath }, {
857
+ cwd: workspace.root,
858
+ root: workspace.root,
859
+ readRoots: readPath.readRoots,
860
+ });
861
+ if (response.isError) {
862
+ logFailedToolResponse(config, {
863
+ tool: toolNames.read,
864
+ workspaceId,
865
+ path: input.path,
866
+ }, response.content, startedAt);
867
+ return response;
868
+ }
869
+ workspaces.markReadPathLoaded(workspace, readPath);
870
+ const summary = {
871
+ ...textSummary(response.content),
872
+ offset: input.offset ?? 1,
873
+ limited: input.limit !== undefined,
874
+ };
875
+ logToolCall(config, {
876
+ tool: toolNames.read,
832
877
  workspaceId,
833
878
  path: input.path,
834
- summary,
835
- payload: { content: response.content },
836
- },
837
- },
838
- structuredContent: {
839
- result: contentText(response.content),
879
+ success: true,
880
+ durationMs: Math.round(performance.now() - startedAt),
881
+ });
882
+ return {
883
+ ...response,
884
+ _meta: {
885
+ tool: toolNames.read,
886
+ card: {
887
+ workspaceId,
888
+ path: input.path,
889
+ summary,
890
+ payload: { content: response.content },
891
+ },
892
+ },
893
+ structuredContent: {
894
+ result: contentText(response.content),
895
+ },
896
+ };
840
897
  },
841
- };
898
+ });
842
899
  });
843
900
  if (config.toolMode !== "codex") {
844
901
  registerAppTool(server, toolNames.write, {
@@ -857,53 +914,62 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
857
914
  ...toolWidgetDescriptorMeta(config, "write"),
858
915
  annotations: WRITE_TOOL_ANNOTATIONS,
859
916
  }, async ({ workspaceId, ...input }) => {
860
- const startedAt = performance.now();
861
917
  const workspace = workspaces.getWorkspace(workspaceId);
862
- workspaces.resolvePath(workspace, input.path);
863
- const response = await writeFileTool(input, {
864
- cwd: workspace.root,
865
- root: workspace.root,
866
- });
867
- if (response.isError) {
868
- logFailedToolResponse(config, {
869
- tool: toolNames.write,
870
- workspaceId,
871
- path: input.path,
872
- }, response.content, startedAt);
873
- return response;
874
- }
875
- const patch = newFilePatch(input.path, input.content);
876
- const stats = countDiffStats(patch);
877
- const summary = {
878
- ...stats,
879
- lines: contentLineCount(input.content),
880
- characters: input.content.length,
881
- };
882
- logToolCall(config, {
918
+ return runToolWithHooks(hooks, {
883
919
  tool: toolNames.write,
884
- workspaceId,
885
- path: input.path,
886
- success: true,
887
- durationMs: Math.round(performance.now() - startedAt),
888
- });
889
- return {
890
- ...response,
891
- _meta: {
892
- tool: toolNames.write,
893
- card: {
920
+ invocation: workspaceHookInvocation(workspace),
921
+ payload: { path: input.path },
922
+ isFailure: toolResultIsError,
923
+ changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
924
+ operation: async () => {
925
+ const startedAt = performance.now();
926
+ workspaces.resolvePath(workspace, input.path);
927
+ const response = await writeFileTool(input, {
928
+ cwd: workspace.root,
929
+ root: workspace.root,
930
+ });
931
+ if (response.isError) {
932
+ logFailedToolResponse(config, {
933
+ tool: toolNames.write,
934
+ workspaceId,
935
+ path: input.path,
936
+ }, response.content, startedAt);
937
+ return response;
938
+ }
939
+ const patch = newFilePatch(input.path, input.content);
940
+ const stats = countDiffStats(patch);
941
+ const summary = {
942
+ ...stats,
943
+ lines: contentLineCount(input.content),
944
+ characters: input.content.length,
945
+ };
946
+ logToolCall(config, {
947
+ tool: toolNames.write,
894
948
  workspaceId,
895
949
  path: input.path,
896
- summary,
897
- payload: {
898
- content: response.content,
899
- patch,
950
+ success: true,
951
+ durationMs: Math.round(performance.now() - startedAt),
952
+ });
953
+ return {
954
+ ...response,
955
+ _meta: {
956
+ tool: toolNames.write,
957
+ card: {
958
+ workspaceId,
959
+ path: input.path,
960
+ summary,
961
+ payload: {
962
+ content: response.content,
963
+ patch,
964
+ },
965
+ },
900
966
  },
901
- },
902
- },
903
- structuredContent: {
904
- result: contentText(response.content),
967
+ structuredContent: {
968
+ result: contentText(response.content),
969
+ },
970
+ };
905
971
  },
906
- };
972
+ });
907
973
  });
908
974
  registerAppTool(server, toolNames.edit, {
909
975
  title: "Edit file",
@@ -930,54 +996,63 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
930
996
  ...toolWidgetDescriptorMeta(config, "edit"),
931
997
  annotations: EDIT_TOOL_ANNOTATIONS,
932
998
  }, async ({ workspaceId, ...input }) => {
933
- const startedAt = performance.now();
934
999
  const workspace = workspaces.getWorkspace(workspaceId);
935
- workspaces.resolvePath(workspace, input.path);
936
- const response = await editFileTool(input, {
937
- cwd: workspace.root,
938
- root: workspace.root,
939
- });
940
- if (response.isError) {
941
- logFailedToolResponse(config, {
942
- tool: toolNames.edit,
943
- workspaceId,
944
- path: input.path,
945
- }, response.content, startedAt);
946
- return response;
947
- }
948
- const stats = countDiffStats(response.details?.patch ?? response.details?.diff);
949
- const summary = {
950
- ...stats,
951
- editCount: input.edits.length,
952
- };
953
- const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`;
954
- const editContent = [textBlock(editResultText)];
955
- logToolCall(config, {
1000
+ return runToolWithHooks(hooks, {
956
1001
  tool: toolNames.edit,
957
- workspaceId,
958
- path: input.path,
959
- success: true,
960
- durationMs: Math.round(performance.now() - startedAt),
961
- });
962
- return {
963
- content: editContent,
964
- _meta: {
965
- tool: toolNames.edit,
966
- card: {
1002
+ invocation: workspaceHookInvocation(workspace),
1003
+ payload: { path: input.path, editCount: input.edits.length },
1004
+ isFailure: toolResultIsError,
1005
+ changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
1006
+ operation: async () => {
1007
+ const startedAt = performance.now();
1008
+ workspaces.resolvePath(workspace, input.path);
1009
+ const response = await editFileTool(input, {
1010
+ cwd: workspace.root,
1011
+ root: workspace.root,
1012
+ });
1013
+ if (response.isError) {
1014
+ logFailedToolResponse(config, {
1015
+ tool: toolNames.edit,
1016
+ workspaceId,
1017
+ path: input.path,
1018
+ }, response.content, startedAt);
1019
+ return response;
1020
+ }
1021
+ const stats = countDiffStats(response.details?.patch ?? response.details?.diff);
1022
+ const summary = {
1023
+ ...stats,
1024
+ editCount: input.edits.length,
1025
+ };
1026
+ const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`;
1027
+ const editContent = [textBlock(editResultText)];
1028
+ logToolCall(config, {
1029
+ tool: toolNames.edit,
967
1030
  workspaceId,
968
1031
  path: input.path,
969
- summary,
970
- payload: {
971
- diff: response.details?.diff,
972
- patch: response.details?.patch,
1032
+ success: true,
1033
+ durationMs: Math.round(performance.now() - startedAt),
1034
+ });
1035
+ return {
1036
+ content: editContent,
1037
+ _meta: {
1038
+ tool: toolNames.edit,
1039
+ card: {
1040
+ workspaceId,
1041
+ path: input.path,
1042
+ summary,
1043
+ payload: {
1044
+ diff: response.details?.diff,
1045
+ patch: response.details?.patch,
1046
+ },
1047
+ },
973
1048
  },
974
- },
975
- },
976
- structuredContent: {
977
- status: "applied",
978
- result: contentText(editContent),
1049
+ structuredContent: {
1050
+ status: "applied",
1051
+ result: contentText(editContent),
1052
+ },
1053
+ };
979
1054
  },
980
- };
1055
+ });
981
1056
  });
982
1057
  }
983
1058
  if (config.toolMode === "codex") {
@@ -1004,44 +1079,53 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1004
1079
  ...toolWidgetDescriptorMeta(config, "edit"),
1005
1080
  annotations: EDIT_TOOL_ANNOTATIONS,
1006
1081
  }, async ({ workspaceId, patch }) => {
1007
- const startedAt = performance.now();
1008
1082
  const workspace = workspaces.getWorkspace(workspaceId);
1009
- const applied = await applyPatch(workspace.root, patch);
1010
- const paths = applied.files.map((file) => file.path).join(", ");
1011
- const result = `Applied patch to ${applied.files.length} file(s): ${paths}`;
1012
- const content = [textBlock(result)];
1013
- const displayPath = applied.files.length === 1
1014
- ? applied.files[0]?.path
1015
- : `${applied.files.length} files`;
1016
- logToolCall(config, {
1083
+ return runToolWithHooks(hooks, {
1017
1084
  tool: "apply_patch",
1018
- workspaceId,
1019
- success: true,
1020
- durationMs: Math.round(performance.now() - startedAt),
1021
- });
1022
- return {
1023
- content,
1024
- _meta: {
1025
- tool: "apply_patch",
1026
- card: {
1085
+ invocation: workspaceHookInvocation(workspace),
1086
+ payload: { patchBytes: Buffer.byteLength(patch) },
1087
+ changedPaths: (response) => Array.from(new Set(response.structuredContent.files.flatMap((file) => [file.previousPath, file.path])
1088
+ .filter((path) => Boolean(path)))),
1089
+ operation: async () => {
1090
+ const startedAt = performance.now();
1091
+ const applied = await applyPatch(workspace.root, patch);
1092
+ const paths = applied.files.map((file) => file.path).join(", ");
1093
+ const result = `Applied patch to ${applied.files.length} file(s): ${paths}`;
1094
+ const content = [textBlock(result)];
1095
+ const displayPath = applied.files.length === 1
1096
+ ? applied.files[0]?.path
1097
+ : `${applied.files.length} files`;
1098
+ logToolCall(config, {
1099
+ tool: "apply_patch",
1027
1100
  workspaceId,
1028
- path: displayPath,
1029
- summary: {
1030
- files: applied.files.length,
1101
+ success: true,
1102
+ durationMs: Math.round(performance.now() - startedAt),
1103
+ });
1104
+ return {
1105
+ content,
1106
+ _meta: {
1107
+ tool: "apply_patch",
1108
+ card: {
1109
+ workspaceId,
1110
+ path: displayPath,
1111
+ summary: {
1112
+ files: applied.files.length,
1113
+ additions: applied.additions,
1114
+ removals: applied.removals,
1115
+ },
1116
+ files: applied.files,
1117
+ payload: { patch: applied.patch },
1118
+ },
1119
+ },
1120
+ structuredContent: {
1121
+ result,
1031
1122
  additions: applied.additions,
1032
1123
  removals: applied.removals,
1124
+ files: applied.files,
1033
1125
  },
1034
- files: applied.files,
1035
- payload: { patch: applied.patch },
1036
- },
1126
+ };
1037
1127
  },
1038
- structuredContent: {
1039
- result,
1040
- additions: applied.additions,
1041
- removals: applied.removals,
1042
- files: applied.files,
1043
- },
1044
- };
1128
+ });
1045
1129
  });
1046
1130
  }
1047
1131
  if (config.widgets === "changes") {
@@ -1057,37 +1141,43 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1057
1141
  ...toolWidgetDescriptorMeta(config, "show_changes"),
1058
1142
  annotations: { readOnlyHint: true },
1059
1143
  }, async ({ workspaceId }) => {
1060
- const startedAt = performance.now();
1061
1144
  const workspace = workspaces.getWorkspace(workspaceId);
1062
- const review = await reviewCheckpoints.reviewChanges({
1063
- workspaceId,
1064
- root: workspace.root,
1065
- markReviewed: true,
1066
- });
1067
- const content = [textBlock(review.result)];
1068
- logToolCall(config, {
1145
+ return runToolWithHooks(hooks, {
1069
1146
  tool: "show_changes",
1070
- workspaceId,
1071
- success: true,
1072
- durationMs: Math.round(performance.now() - startedAt),
1073
- });
1074
- return {
1075
- content,
1076
- _meta: {
1077
- tool: "show_changes",
1078
- card: {
1147
+ invocation: workspaceHookInvocation(workspace),
1148
+ operation: async () => {
1149
+ const startedAt = performance.now();
1150
+ const review = await reviewCheckpoints.reviewChanges({
1151
+ workspaceId,
1152
+ root: workspace.root,
1153
+ markReviewed: true,
1154
+ });
1155
+ const content = [textBlock(review.result)];
1156
+ logToolCall(config, {
1157
+ tool: "show_changes",
1079
1158
  workspaceId,
1080
- summary: review.summary,
1081
- files: review.files,
1082
- payload: {
1083
- patch: review.patch,
1159
+ success: true,
1160
+ durationMs: Math.round(performance.now() - startedAt),
1161
+ });
1162
+ return {
1163
+ content,
1164
+ _meta: {
1165
+ tool: "show_changes",
1166
+ card: {
1167
+ workspaceId,
1168
+ summary: review.summary,
1169
+ files: review.files,
1170
+ payload: {
1171
+ patch: review.patch,
1172
+ },
1173
+ },
1084
1174
  },
1085
- },
1086
- },
1087
- structuredContent: {
1088
- result: contentText(content),
1175
+ structuredContent: {
1176
+ result: contentText(content),
1177
+ },
1178
+ };
1089
1179
  },
1090
- };
1180
+ });
1091
1181
  });
1092
1182
  }
1093
1183
  if (config.toolMode === "full") {
@@ -1109,49 +1199,57 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1109
1199
  ...toolWidgetDescriptorMeta(config, "search"),
1110
1200
  annotations: { readOnlyHint: true },
1111
1201
  }, async ({ workspaceId, ...input }) => {
1112
- const startedAt = performance.now();
1113
1202
  const workspace = workspaces.getWorkspace(workspaceId);
1114
- if (input.path)
1115
- workspaces.resolvePath(workspace, input.path);
1116
- const response = await grepFilesTool(input, {
1117
- cwd: workspace.root,
1118
- root: workspace.root,
1119
- });
1120
- if (response.isError) {
1121
- logFailedToolResponse(config, {
1122
- tool: toolNames.grep,
1123
- workspaceId,
1124
- path: input.path,
1125
- }, response.content, startedAt);
1126
- return response;
1127
- }
1128
- const summary = {
1129
- pattern: input.pattern,
1130
- scope: input.path ?? ".",
1131
- ...textSummary(response.content),
1132
- };
1133
- logToolCall(config, {
1203
+ return runToolWithHooks(hooks, {
1134
1204
  tool: toolNames.grep,
1135
- workspaceId,
1136
- path: input.path,
1137
- success: true,
1138
- durationMs: Math.round(performance.now() - startedAt),
1139
- });
1140
- return {
1141
- ...response,
1142
- _meta: {
1143
- tool: toolNames.grep,
1144
- card: {
1205
+ invocation: workspaceHookInvocation(workspace),
1206
+ payload: { pattern: input.pattern, path: input.path, include: input.include },
1207
+ isFailure: toolResultIsError,
1208
+ operation: async () => {
1209
+ const startedAt = performance.now();
1210
+ if (input.path)
1211
+ workspaces.resolvePath(workspace, input.path);
1212
+ const response = await grepFilesTool(input, {
1213
+ cwd: workspace.root,
1214
+ root: workspace.root,
1215
+ });
1216
+ if (response.isError) {
1217
+ logFailedToolResponse(config, {
1218
+ tool: toolNames.grep,
1219
+ workspaceId,
1220
+ path: input.path,
1221
+ }, response.content, startedAt);
1222
+ return response;
1223
+ }
1224
+ const summary = {
1225
+ pattern: input.pattern,
1226
+ scope: input.path ?? ".",
1227
+ ...textSummary(response.content),
1228
+ };
1229
+ logToolCall(config, {
1230
+ tool: toolNames.grep,
1145
1231
  workspaceId,
1146
1232
  path: input.path,
1147
- summary,
1148
- payload: { content: response.content },
1149
- },
1150
- },
1151
- structuredContent: {
1152
- result: contentText(response.content),
1233
+ success: true,
1234
+ durationMs: Math.round(performance.now() - startedAt),
1235
+ });
1236
+ return {
1237
+ ...response,
1238
+ _meta: {
1239
+ tool: toolNames.grep,
1240
+ card: {
1241
+ workspaceId,
1242
+ path: input.path,
1243
+ summary,
1244
+ payload: { content: response.content },
1245
+ },
1246
+ },
1247
+ structuredContent: {
1248
+ result: contentText(response.content),
1249
+ },
1250
+ };
1153
1251
  },
1154
- };
1252
+ });
1155
1253
  });
1156
1254
  registerAppTool(server, toolNames.glob, {
1157
1255
  title: "Glob",
@@ -1170,49 +1268,57 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1170
1268
  ...toolWidgetDescriptorMeta(config, "search"),
1171
1269
  annotations: { readOnlyHint: true },
1172
1270
  }, async ({ workspaceId, ...input }) => {
1173
- const startedAt = performance.now();
1174
1271
  const workspace = workspaces.getWorkspace(workspaceId);
1175
- if (input.path)
1176
- workspaces.resolvePath(workspace, input.path);
1177
- const response = await findFilesTool(input, {
1178
- cwd: workspace.root,
1179
- root: workspace.root,
1180
- });
1181
- if (response.isError) {
1182
- logFailedToolResponse(config, {
1183
- tool: toolNames.glob,
1184
- workspaceId,
1185
- path: input.path,
1186
- }, response.content, startedAt);
1187
- return response;
1188
- }
1189
- const summary = {
1190
- pattern: input.pattern,
1191
- scope: input.path ?? ".",
1192
- ...textSummary(response.content),
1193
- };
1194
- logToolCall(config, {
1272
+ return runToolWithHooks(hooks, {
1195
1273
  tool: toolNames.glob,
1196
- workspaceId,
1197
- path: input.path,
1198
- success: true,
1199
- durationMs: Math.round(performance.now() - startedAt),
1200
- });
1201
- return {
1202
- ...response,
1203
- _meta: {
1204
- tool: toolNames.glob,
1205
- card: {
1274
+ invocation: workspaceHookInvocation(workspace),
1275
+ payload: { pattern: input.pattern, path: input.path },
1276
+ isFailure: toolResultIsError,
1277
+ operation: async () => {
1278
+ const startedAt = performance.now();
1279
+ if (input.path)
1280
+ workspaces.resolvePath(workspace, input.path);
1281
+ const response = await findFilesTool(input, {
1282
+ cwd: workspace.root,
1283
+ root: workspace.root,
1284
+ });
1285
+ if (response.isError) {
1286
+ logFailedToolResponse(config, {
1287
+ tool: toolNames.glob,
1288
+ workspaceId,
1289
+ path: input.path,
1290
+ }, response.content, startedAt);
1291
+ return response;
1292
+ }
1293
+ const summary = {
1294
+ pattern: input.pattern,
1295
+ scope: input.path ?? ".",
1296
+ ...textSummary(response.content),
1297
+ };
1298
+ logToolCall(config, {
1299
+ tool: toolNames.glob,
1206
1300
  workspaceId,
1207
1301
  path: input.path,
1208
- summary,
1209
- payload: { content: response.content },
1210
- },
1211
- },
1212
- structuredContent: {
1213
- result: contentText(response.content),
1302
+ success: true,
1303
+ durationMs: Math.round(performance.now() - startedAt),
1304
+ });
1305
+ return {
1306
+ ...response,
1307
+ _meta: {
1308
+ tool: toolNames.glob,
1309
+ card: {
1310
+ workspaceId,
1311
+ path: input.path,
1312
+ summary,
1313
+ payload: { content: response.content },
1314
+ },
1315
+ },
1316
+ structuredContent: {
1317
+ result: contentText(response.content),
1318
+ },
1319
+ };
1214
1320
  },
1215
- };
1321
+ });
1216
1322
  });
1217
1323
  registerAppTool(server, toolNames.ls, {
1218
1324
  title: "Ls",
@@ -1229,44 +1335,52 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1229
1335
  ...toolWidgetDescriptorMeta(config, "directory"),
1230
1336
  annotations: { readOnlyHint: true },
1231
1337
  }, async ({ workspaceId, ...input }) => {
1232
- const startedAt = performance.now();
1233
1338
  const workspace = workspaces.getWorkspace(workspaceId);
1234
- workspaces.resolvePath(workspace, input.path);
1235
- const response = await listDirectoryTool(input, {
1236
- cwd: workspace.root,
1237
- root: workspace.root,
1238
- });
1239
- if (response.isError) {
1240
- logFailedToolResponse(config, {
1241
- tool: toolNames.ls,
1242
- workspaceId,
1243
- path: input.path,
1244
- }, response.content, startedAt);
1245
- return response;
1246
- }
1247
- const summary = textSummary(response.content);
1248
- logToolCall(config, {
1339
+ return runToolWithHooks(hooks, {
1249
1340
  tool: toolNames.ls,
1250
- workspaceId,
1251
- path: input.path,
1252
- success: true,
1253
- durationMs: Math.round(performance.now() - startedAt),
1254
- });
1255
- return {
1256
- ...response,
1257
- _meta: {
1258
- tool: toolNames.ls,
1259
- card: {
1341
+ invocation: workspaceHookInvocation(workspace),
1342
+ payload: { path: input.path },
1343
+ isFailure: toolResultIsError,
1344
+ operation: async () => {
1345
+ const startedAt = performance.now();
1346
+ workspaces.resolvePath(workspace, input.path);
1347
+ const response = await listDirectoryTool(input, {
1348
+ cwd: workspace.root,
1349
+ root: workspace.root,
1350
+ });
1351
+ if (response.isError) {
1352
+ logFailedToolResponse(config, {
1353
+ tool: toolNames.ls,
1354
+ workspaceId,
1355
+ path: input.path,
1356
+ }, response.content, startedAt);
1357
+ return response;
1358
+ }
1359
+ const summary = textSummary(response.content);
1360
+ logToolCall(config, {
1361
+ tool: toolNames.ls,
1260
1362
  workspaceId,
1261
1363
  path: input.path,
1262
- summary,
1263
- payload: { content: response.content },
1264
- },
1265
- },
1266
- structuredContent: {
1267
- result: contentText(response.content),
1364
+ success: true,
1365
+ durationMs: Math.round(performance.now() - startedAt),
1366
+ });
1367
+ return {
1368
+ ...response,
1369
+ _meta: {
1370
+ tool: toolNames.ls,
1371
+ card: {
1372
+ workspaceId,
1373
+ path: input.path,
1374
+ summary,
1375
+ payload: { content: response.content },
1376
+ },
1377
+ },
1378
+ structuredContent: {
1379
+ result: contentText(response.content),
1380
+ },
1381
+ };
1268
1382
  },
1269
- };
1383
+ });
1270
1384
  });
1271
1385
  }
1272
1386
  if (config.toolMode !== "codex") {
@@ -1295,61 +1409,74 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1295
1409
  ...toolWidgetDescriptorMeta(config, "shell"),
1296
1410
  annotations: SHELL_TOOL_ANNOTATIONS,
1297
1411
  }, async ({ workspaceId, workingDirectory, ...input }) => {
1298
- const startedAt = performance.now();
1299
1412
  const workspace = workspaces.getWorkspace(workspaceId);
1300
- const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
1301
- const response = await runShellTool(input, {
1302
- cwd,
1303
- root: workspace.root,
1304
- });
1305
- if (response.isError) {
1306
- logFailedToolResponse(config, {
1307
- tool: toolNames.shell,
1308
- workspaceId,
1309
- workingDirectory: workingDirectory ?? ".",
1310
- command: input.command,
1311
- commandLength: input.command.length,
1312
- }, response.content, startedAt);
1313
- return response;
1314
- }
1315
- const summary = {
1316
- command: input.command,
1317
- workingDirectory: workingDirectory ?? ".",
1318
- ...textSummary(response.content),
1319
- };
1320
- logToolCall(config, {
1413
+ return runToolWithHooks(hooks, {
1321
1414
  tool: toolNames.shell,
1322
- workspaceId,
1323
- workingDirectory: workingDirectory ?? ".",
1324
- command: input.command,
1325
- commandLength: input.command.length,
1326
- success: true,
1327
- durationMs: Math.round(performance.now() - startedAt),
1328
- });
1329
- return {
1330
- ...response,
1331
- _meta: {
1332
- tool: toolNames.shell,
1333
- card: {
1334
- workspaceId,
1335
- path: workingDirectory,
1336
- summary,
1337
- payload: { content: response.content },
1338
- },
1415
+ invocation: workspaceHookInvocation(workspace),
1416
+ payload: {
1417
+ command: input.command,
1418
+ workingDirectory: workingDirectory ?? ".",
1419
+ timeoutSeconds: input.timeout,
1339
1420
  },
1340
- structuredContent: {
1341
- result: contentText(response.content),
1421
+ isFailure: toolResultIsError,
1422
+ operation: async () => {
1423
+ const startedAt = performance.now();
1424
+ const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
1425
+ const response = await runShellTool(input, {
1426
+ cwd,
1427
+ root: workspace.root,
1428
+ });
1429
+ if (response.isError) {
1430
+ logFailedToolResponse(config, {
1431
+ tool: toolNames.shell,
1432
+ workspaceId,
1433
+ workingDirectory: workingDirectory ?? ".",
1434
+ command: input.command,
1435
+ commandLength: input.command.length,
1436
+ }, response.content, startedAt);
1437
+ return response;
1438
+ }
1439
+ const summary = {
1440
+ command: input.command,
1441
+ workingDirectory: workingDirectory ?? ".",
1442
+ ...textSummary(response.content),
1443
+ };
1444
+ logToolCall(config, {
1445
+ tool: toolNames.shell,
1446
+ workspaceId,
1447
+ workingDirectory: workingDirectory ?? ".",
1448
+ command: input.command,
1449
+ commandLength: input.command.length,
1450
+ success: true,
1451
+ durationMs: Math.round(performance.now() - startedAt),
1452
+ });
1453
+ return {
1454
+ ...response,
1455
+ _meta: {
1456
+ tool: toolNames.shell,
1457
+ card: {
1458
+ workspaceId,
1459
+ path: workingDirectory,
1460
+ summary,
1461
+ payload: { content: response.content },
1462
+ },
1463
+ },
1464
+ structuredContent: {
1465
+ result: contentText(response.content),
1466
+ },
1467
+ };
1342
1468
  },
1343
- };
1469
+ });
1344
1470
  });
1345
1471
  }
1346
1472
  if (config.toolMode === "codex") {
1347
- registerCodexProcessTools(server, config, workspaces, processSessions);
1473
+ registerCodexProcessTools(server, config, workspaces, processSessions, hooks);
1348
1474
  }
1349
1475
  if (config.artifactsEnabled && isArtifactDownloadSupportedPlatform()) {
1350
1476
  registerArtifactTools(server, {
1351
1477
  config,
1352
1478
  workspaces,
1479
+ hooks,
1353
1480
  incomingArtifactAdapters,
1354
1481
  });
1355
1482
  }