opencode-gitlab-duo-agentic 0.1.24 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +305 -13
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -10,7 +10,6 @@ var WORKFLOW_CONNECT_TIMEOUT_MS = 15e3;
10
10
  var WORKFLOW_HEARTBEAT_INTERVAL_MS = 6e4;
11
11
  var WORKFLOW_KEEPALIVE_INTERVAL_MS = 45e3;
12
12
  var WORKFLOW_TOKEN_EXPIRY_BUFFER_MS = 3e4;
13
- var WORKFLOW_TOOL_ERROR_MESSAGE = "Tool execution is not implemented in this client yet";
14
13
 
15
14
  // src/gitlab/models.ts
16
15
  import crypto from "crypto";
@@ -136,8 +135,8 @@ async function resolveRootNamespaceId(client, namespaceId) {
136
135
  }
137
136
  async function readGitConfig(cwd) {
138
137
  const gitPath = path.join(cwd, ".git");
139
- const stat = await fs.stat(gitPath);
140
- if (stat.isDirectory()) {
138
+ const stat2 = await fs.stat(gitPath);
139
+ if (stat2.isDirectory()) {
141
140
  return fs.readFile(path.join(gitPath, "config"), "utf8");
142
141
  }
143
142
  const content = await fs.readFile(gitPath, "utf8");
@@ -412,7 +411,7 @@ var AsyncQueue = class {
412
411
  shift() {
413
412
  const value = this.#values.shift();
414
413
  if (value !== void 0) return Promise.resolve(value);
415
- return new Promise((resolve) => this.#waiters.push(resolve));
414
+ return new Promise((resolve2) => this.#waiters.push(resolve2));
416
415
  }
417
416
  };
418
417
 
@@ -543,7 +542,7 @@ var WorkflowWebSocketClient = class {
543
542
  async connect(url, headers) {
544
543
  const socket = new WebSocket(url, { headers });
545
544
  this.#socket = socket;
546
- await new Promise((resolve, reject) => {
545
+ await new Promise((resolve2, reject) => {
547
546
  const timeout = setTimeout(() => {
548
547
  cleanup();
549
548
  socket.close(1e3);
@@ -556,7 +555,7 @@ var WorkflowWebSocketClient = class {
556
555
  };
557
556
  const onOpen = () => {
558
557
  cleanup();
559
- resolve();
558
+ resolve2();
560
559
  };
561
560
  const onError = (error) => {
562
561
  cleanup();
@@ -621,6 +620,268 @@ function decodeSocketMessage(data) {
621
620
  return void 0;
622
621
  }
623
622
 
623
+ // src/workflow/tool-executor.ts
624
+ import { readFile, readdir, writeFile, mkdir as fsMkdir, stat } from "fs/promises";
625
+ import { exec } from "child_process";
626
+ import { resolve, dirname } from "path";
627
+ var ToolExecutor = class {
628
+ #cwd;
629
+ #client;
630
+ constructor(cwd, client) {
631
+ this.#cwd = cwd;
632
+ this.#client = client;
633
+ }
634
+ /**
635
+ * Route a Duo WorkflowToolAction to the appropriate local handler.
636
+ * This is the main bridge entry point.
637
+ */
638
+ async executeAction(action) {
639
+ try {
640
+ if (action.runReadFile) {
641
+ return this.#read({
642
+ filePath: action.runReadFile.filepath,
643
+ offset: action.runReadFile.offset,
644
+ limit: action.runReadFile.limit
645
+ });
646
+ }
647
+ if (action.runReadFiles) {
648
+ return this.#readMultiple(action.runReadFiles.filepaths);
649
+ }
650
+ if (action.runWriteFile) {
651
+ return this.#write({
652
+ filePath: action.runWriteFile.filepath,
653
+ content: action.runWriteFile.contents
654
+ });
655
+ }
656
+ if (action.runEditFile) {
657
+ return this.#edit({
658
+ filePath: action.runEditFile.filepath,
659
+ oldString: action.runEditFile.oldString,
660
+ newString: action.runEditFile.newString
661
+ });
662
+ }
663
+ if (action.runShellCommand) {
664
+ return this.#bash({ command: action.runShellCommand.command });
665
+ }
666
+ if (action.runCommand) {
667
+ const parts = [action.runCommand.program];
668
+ if (action.runCommand.flags) parts.push(...action.runCommand.flags);
669
+ if (action.runCommand.arguments) parts.push(...action.runCommand.arguments);
670
+ return this.#bash({ command: parts.join(" ") });
671
+ }
672
+ if (action.runGitCommand) {
673
+ const cmd = action.runGitCommand.arguments ? `git ${action.runGitCommand.command} ${action.runGitCommand.arguments}` : `git ${action.runGitCommand.command}`;
674
+ return this.#bash({ command: cmd });
675
+ }
676
+ if (action.listDirectory) {
677
+ return this.#read({ filePath: action.listDirectory.directory });
678
+ }
679
+ if (action.grep) {
680
+ return this.#grep({
681
+ pattern: action.grep.pattern,
682
+ path: action.grep.search_directory,
683
+ caseInsensitive: action.grep.case_insensitive
684
+ });
685
+ }
686
+ if (action.findFiles) {
687
+ return this.#glob({ pattern: action.findFiles.name_pattern });
688
+ }
689
+ if (action.mkdir) {
690
+ return this.#mkdir(action.mkdir.directory_path);
691
+ }
692
+ if (action.runMCPTool) {
693
+ return this.#executeMcpTool(action.runMCPTool.name, action.runMCPTool.args);
694
+ }
695
+ if (action.runHTTPRequest) {
696
+ return this.#httpRequest(action.runHTTPRequest);
697
+ }
698
+ return { response: "", error: "Unknown tool action" };
699
+ } catch (err) {
700
+ return { response: "", error: err instanceof Error ? err.message : String(err) };
701
+ }
702
+ }
703
+ /**
704
+ * Execute an MCP tool by name (for runMCPTool actions).
705
+ * Routes to the appropriate handler based on tool name.
706
+ */
707
+ async #executeMcpTool(name, argsJson) {
708
+ const args = argsJson ? JSON.parse(argsJson) : {};
709
+ switch (name) {
710
+ case "bash":
711
+ return this.#bash(args);
712
+ case "read":
713
+ return this.#read(args);
714
+ case "edit":
715
+ return this.#edit(args);
716
+ case "write":
717
+ return this.#write(args);
718
+ case "glob":
719
+ return this.#glob(args);
720
+ case "grep":
721
+ return this.#grep(args);
722
+ default:
723
+ return { response: "", error: `Unknown MCP tool: ${name}` };
724
+ }
725
+ }
726
+ // ── Handlers ──────────────────────────────────────────────────────
727
+ async #bash(args) {
728
+ const cwd = args.workdir ? resolve(this.#cwd, args.workdir) : this.#cwd;
729
+ const timeout = args.timeout ?? 12e4;
730
+ return new Promise((res) => {
731
+ exec(args.command, { cwd, timeout, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
732
+ const output = [stdout, stderr].filter(Boolean).join("\n");
733
+ if (error) {
734
+ res({ response: output, error: error.message });
735
+ return;
736
+ }
737
+ res({ response: output });
738
+ });
739
+ });
740
+ }
741
+ async #read(args) {
742
+ try {
743
+ const filepath = resolve(this.#cwd, args.filePath);
744
+ const info = await stat(filepath);
745
+ if (info.isDirectory()) {
746
+ const entries = await readdir(filepath, { withFileTypes: true });
747
+ const lines = entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name);
748
+ return { response: lines.join("\n") };
749
+ }
750
+ const content = await readFile(filepath, "utf-8");
751
+ const allLines = content.split("\n");
752
+ const offset = Math.max((args.offset ?? 1) - 1, 0);
753
+ const limit = args.limit ?? 2e3;
754
+ const slice = allLines.slice(offset, offset + limit);
755
+ const numbered = slice.map((line, i) => `${offset + i + 1}: ${line}`);
756
+ return { response: numbered.join("\n") };
757
+ } catch (err) {
758
+ return { response: "", error: err instanceof Error ? err.message : String(err) };
759
+ }
760
+ }
761
+ async #readMultiple(filepaths) {
762
+ const results = [];
763
+ for (const fp of filepaths) {
764
+ const result = await this.#read({ filePath: fp });
765
+ if (result.error) {
766
+ results.push(`--- ${fp} ---
767
+ ERROR: ${result.error}`);
768
+ } else {
769
+ results.push(`--- ${fp} ---
770
+ ${result.response}`);
771
+ }
772
+ }
773
+ return { response: results.join("\n\n") };
774
+ }
775
+ async #edit(args) {
776
+ try {
777
+ const filepath = resolve(this.#cwd, args.filePath);
778
+ let content = await readFile(filepath, "utf-8");
779
+ if (args.replaceAll) {
780
+ if (!content.includes(args.oldString)) {
781
+ return { response: "", error: "oldString not found in content" };
782
+ }
783
+ content = content.replaceAll(args.oldString, args.newString);
784
+ } else {
785
+ const idx = content.indexOf(args.oldString);
786
+ if (idx === -1) {
787
+ return { response: "", error: "oldString not found in content" };
788
+ }
789
+ const secondIdx = content.indexOf(args.oldString, idx + 1);
790
+ if (secondIdx !== -1) {
791
+ return { response: "", error: "Found multiple matches for oldString. Provide more surrounding lines to identify the correct match." };
792
+ }
793
+ content = content.slice(0, idx) + args.newString + content.slice(idx + args.oldString.length);
794
+ }
795
+ await writeFile(filepath, content, "utf-8");
796
+ return { response: `Successfully edited ${args.filePath}` };
797
+ } catch (err) {
798
+ return { response: "", error: err instanceof Error ? err.message : String(err) };
799
+ }
800
+ }
801
+ async #write(args) {
802
+ try {
803
+ const filepath = resolve(this.#cwd, args.filePath);
804
+ await fsMkdir(dirname(filepath), { recursive: true });
805
+ await writeFile(filepath, args.content, "utf-8");
806
+ return { response: `Successfully wrote ${args.filePath}` };
807
+ } catch (err) {
808
+ return { response: "", error: err instanceof Error ? err.message : String(err) };
809
+ }
810
+ }
811
+ async #glob(args) {
812
+ const cwd = args.path ? resolve(this.#cwd, args.path) : this.#cwd;
813
+ return new Promise((res) => {
814
+ const command = process.platform === "win32" ? `dir /s /b "${args.pattern}"` : `find . -path "./${args.pattern}" -not -path "*/node_modules/*" -not -path "*/.git/*" 2>/dev/null | head -200`;
815
+ exec(command, { cwd, timeout: 3e4 }, (error, stdout) => {
816
+ if (error && !stdout) {
817
+ exec(`ls -1 ${args.pattern} 2>/dev/null | head -200`, { cwd, timeout: 3e4 }, (_err, out) => {
818
+ res({ response: out.trim() || "No matches found" });
819
+ });
820
+ return;
821
+ }
822
+ const lines = stdout.trim().split("\n").filter(Boolean);
823
+ res({ response: lines.length > 0 ? lines.join("\n") : "No matches found" });
824
+ });
825
+ });
826
+ }
827
+ async #grep(args) {
828
+ const cwd = args.path ? resolve(this.#cwd, args.path) : this.#cwd;
829
+ const caseFlag = args.caseInsensitive ? "--ignore-case" : "";
830
+ return new Promise((res) => {
831
+ const includeFlag = args.include ? `--glob '${args.include}'` : "";
832
+ const escapedPattern = args.pattern.replace(/'/g, "'\\''");
833
+ const rgCommand = `rg --line-number --no-heading ${caseFlag} ${includeFlag} '${escapedPattern}' . 2>/dev/null | head -500`;
834
+ exec(rgCommand, { cwd, timeout: 3e4, maxBuffer: 5 * 1024 * 1024 }, (_error, stdout) => {
835
+ if (stdout.trim()) {
836
+ res({ response: stdout.trim() });
837
+ return;
838
+ }
839
+ const grepCaseFlag = args.caseInsensitive ? "-i" : "";
840
+ const grepInclude = args.include ? `--include='${args.include}'` : "";
841
+ const grepCommand = `grep -rn ${grepCaseFlag} ${grepInclude} '${escapedPattern}' . 2>/dev/null | head -500`;
842
+ exec(grepCommand, { cwd, timeout: 3e4, maxBuffer: 5 * 1024 * 1024 }, (_err, out) => {
843
+ res({ response: out.trim() || "No matches found" });
844
+ });
845
+ });
846
+ });
847
+ }
848
+ async #mkdir(directoryPath) {
849
+ try {
850
+ const filepath = resolve(this.#cwd, directoryPath);
851
+ await fsMkdir(filepath, { recursive: true });
852
+ return { response: `Created directory ${directoryPath}` };
853
+ } catch (err) {
854
+ return { response: "", error: err instanceof Error ? err.message : String(err) };
855
+ }
856
+ }
857
+ async #httpRequest(args) {
858
+ if (!this.#client) {
859
+ return { response: "", error: "HTTP requests require GitLab client credentials" };
860
+ }
861
+ try {
862
+ const url = `${this.#client.instanceUrl}/api/v4/${args.path}`;
863
+ const headers = {
864
+ authorization: `Bearer ${this.#client.token}`
865
+ };
866
+ if (args.body) {
867
+ headers["content-type"] = "application/json";
868
+ }
869
+ const response = await fetch(url, {
870
+ method: args.method,
871
+ headers,
872
+ body: args.body ?? void 0
873
+ });
874
+ const text2 = await response.text();
875
+ if (!response.ok) {
876
+ return { response: text2, error: `HTTP ${response.status}: ${response.statusText}` };
877
+ }
878
+ return { response: text2 };
879
+ } catch (err) {
880
+ return { response: "", error: err instanceof Error ? err.message : String(err) };
881
+ }
882
+ }
883
+ };
884
+
624
885
  // src/workflow/session.ts
625
886
  var WorkflowSession = class {
626
887
  #client;
@@ -631,6 +892,8 @@ var WorkflowSession = class {
631
892
  #projectPath;
632
893
  #rootNamespaceId;
633
894
  #checkpoint = createCheckpointState();
895
+ #toolsConfig;
896
+ #toolExecutor;
634
897
  /** Mutex: serialises concurrent calls to runTurn so only one runs at a time. */
635
898
  #turnLock = Promise.resolve();
636
899
  constructor(client, modelId, cwd) {
@@ -638,6 +901,14 @@ var WorkflowSession = class {
638
901
  this.#tokenService = new WorkflowTokenService(client);
639
902
  this.#modelId = modelId;
640
903
  this.#cwd = cwd;
904
+ this.#toolExecutor = new ToolExecutor(cwd, client);
905
+ }
906
+ /**
907
+ * Opt-in: override the server-side system prompt and/or register MCP tools.
908
+ * When not called, the server uses its default prompt and built-in tools.
909
+ */
910
+ setToolsConfig(config) {
911
+ this.#toolsConfig = config;
641
912
  }
642
913
  get workflowId() {
643
914
  return this.#workflowId;
@@ -649,9 +920,9 @@ var WorkflowSession = class {
649
920
  }
650
921
  async *runTurn(goal, abortSignal) {
651
922
  await this.#turnLock;
652
- let resolve;
923
+ let resolve2;
653
924
  this.#turnLock = new Promise((r) => {
654
- resolve = r;
925
+ resolve2 = r;
655
926
  });
656
927
  const queue = new AsyncQueue();
657
928
  const socket = new WorkflowWebSocketClient({
@@ -679,6 +950,8 @@ var WorkflowSession = class {
679
950
  "x-gitlab-client-type": "node-websocket"
680
951
  });
681
952
  abortSignal?.addEventListener("abort", onAbort, { once: true });
953
+ const mcpTools = this.#toolsConfig?.mcpTools ?? [];
954
+ const preapprovedTools = mcpTools.map((t) => t.name);
682
955
  const sent = socket.send({
683
956
  startRequest: {
684
957
  workflowID: this.#workflowId,
@@ -689,9 +962,14 @@ var WorkflowSession = class {
689
962
  extended_logging: access?.workflow_metadata?.extended_logging ?? false
690
963
  }),
691
964
  clientCapabilities: [],
692
- mcpTools: [],
965
+ mcpTools,
693
966
  additional_context: [],
694
- preapproved_tools: []
967
+ preapproved_tools: preapprovedTools,
968
+ // Only include flowConfig when explicitly configured (opt-in prompt override)
969
+ ...this.#toolsConfig?.flowConfig ? {
970
+ flowConfig: this.#toolsConfig.flowConfig,
971
+ flowConfigSchemaVersion: this.#toolsConfig.flowConfigSchemaVersion ?? "v1"
972
+ } : {}
695
973
  }
696
974
  });
697
975
  if (!sent) throw new Error("failed to send workflow startRequest");
@@ -716,12 +994,13 @@ var WorkflowSession = class {
716
994
  continue;
717
995
  }
718
996
  if (!event.action.requestID) continue;
997
+ const result = await this.#toolExecutor.executeAction(event.action);
719
998
  socket.send({
720
999
  actionResponse: {
721
1000
  requestID: event.action.requestID,
722
1001
  plainTextResponse: {
723
- response: "",
724
- error: WORKFLOW_TOOL_ERROR_MESSAGE
1002
+ response: result.response,
1003
+ error: result.error ?? ""
725
1004
  }
726
1005
  }
727
1006
  });
@@ -729,7 +1008,7 @@ var WorkflowSession = class {
729
1008
  } finally {
730
1009
  abortSignal?.removeEventListener("abort", onAbort);
731
1010
  socket.close();
732
- resolve();
1011
+ resolve2();
733
1012
  }
734
1013
  }
735
1014
  async #createWorkflow(goal) {
@@ -826,11 +1105,23 @@ var DuoWorkflowModel = class {
826
1105
  supportedUrls = {};
827
1106
  #client;
828
1107
  #cwd;
1108
+ #toolsConfig;
829
1109
  constructor(modelId, client, cwd) {
830
1110
  this.modelId = modelId;
831
1111
  this.#client = client;
832
1112
  this.#cwd = cwd ?? process.cwd();
833
1113
  }
1114
+ /**
1115
+ * Opt-in: override the server-side system prompt and/or register MCP tools.
1116
+ * When not called, the server uses its default prompt and built-in tools.
1117
+ * Tool execution is always bridged locally regardless of this setting.
1118
+ */
1119
+ setToolsConfig(config) {
1120
+ this.#toolsConfig = config;
1121
+ for (const session of sessions.values()) {
1122
+ session.setToolsConfig(config);
1123
+ }
1124
+ }
834
1125
  async doGenerate(options) {
835
1126
  const sessionID = readSessionID(options);
836
1127
  if (!sessionID) throw new Error("missing workflow session ID");
@@ -928,6 +1219,7 @@ var DuoWorkflowModel = class {
928
1219
  const existing = sessions.get(key);
929
1220
  if (existing) return existing;
930
1221
  const created = new WorkflowSession(this.#client, this.modelId, this.#cwd);
1222
+ if (this.#toolsConfig) created.setToolsConfig(this.#toolsConfig);
931
1223
  sessions.set(key, created);
932
1224
  return created;
933
1225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-gitlab-duo-agentic",
3
- "version": "0.1.24",
3
+ "version": "0.2.2",
4
4
  "description": "OpenCode plugin and provider for GitLab Duo Agentic workflows",
5
5
  "license": "MIT",
6
6
  "type": "module",