opencode-gitlab-duo-agentic-custom-tools 0.3.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/index.js ADDED
@@ -0,0 +1,1981 @@
1
+ // src/plugin/hooks.ts
2
+ import { tool } from "@opencode-ai/plugin";
3
+
4
+ // src/constants.ts
5
+ var PROVIDER_ID = "gitlab";
6
+ var DEFAULT_MODEL_ID = "duo-chat-sonnet-4-5";
7
+ var DEFAULT_INSTANCE_URL = "https://gitlab.com";
8
+ var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
9
+ var WORKFLOW_DEFINITION = "chat";
10
+ var WORKFLOW_CLIENT_VERSION = "1.0";
11
+ var WORKFLOW_ENVIRONMENT = "ide";
12
+ var WORKFLOW_CONNECT_TIMEOUT_MS = 15e3;
13
+ var WORKFLOW_HEARTBEAT_INTERVAL_MS = 6e4;
14
+ var WORKFLOW_KEEPALIVE_INTERVAL_MS = 45e3;
15
+ var WORKFLOW_TOKEN_EXPIRY_BUFFER_MS = 3e4;
16
+
17
+ // src/gitlab/models.ts
18
+ import crypto from "crypto";
19
+ import fs2 from "fs/promises";
20
+ import os from "os";
21
+ import path2 from "path";
22
+ import { z } from "zod";
23
+
24
+ // src/gitlab/client.ts
25
+ var GitLabApiError = class extends Error {
26
+ constructor(status, message) {
27
+ super(message);
28
+ this.status = status;
29
+ this.name = "GitLabApiError";
30
+ }
31
+ };
32
+ async function request(options, path3, init) {
33
+ const url = `${options.instanceUrl}/api/v4/${path3}`;
34
+ const headers = new Headers(init.headers);
35
+ headers.set("authorization", `Bearer ${options.token}`);
36
+ const response = await fetch(url, {
37
+ ...init,
38
+ headers
39
+ });
40
+ return response;
41
+ }
42
+ async function get(options, path3) {
43
+ const response = await request(options, path3, { method: "GET" });
44
+ if (!response.ok) {
45
+ const text2 = await response.text().catch(() => "");
46
+ throw new GitLabApiError(response.status, `GET ${path3} failed (${response.status}): ${text2}`);
47
+ }
48
+ return response.json();
49
+ }
50
+ async function post(options, path3, body) {
51
+ const response = await request(options, path3, {
52
+ method: "POST",
53
+ headers: {
54
+ "content-type": "application/json"
55
+ },
56
+ body: JSON.stringify(body)
57
+ });
58
+ if (!response.ok) {
59
+ const text2 = await response.text().catch(() => "");
60
+ throw new GitLabApiError(response.status, `POST ${path3} failed (${response.status}): ${text2}`);
61
+ }
62
+ return response.json();
63
+ }
64
+ async function graphql(options, query, variables) {
65
+ const url = `${options.instanceUrl}/api/graphql`;
66
+ const response = await fetch(url, {
67
+ method: "POST",
68
+ headers: {
69
+ "content-type": "application/json",
70
+ authorization: `Bearer ${options.token}`
71
+ },
72
+ body: JSON.stringify({ query, variables })
73
+ });
74
+ if (!response.ok) {
75
+ const text2 = await response.text().catch(() => "");
76
+ throw new GitLabApiError(response.status, `GraphQL request failed (${response.status}): ${text2}`);
77
+ }
78
+ const result = await response.json();
79
+ if (result.errors?.length) {
80
+ throw new GitLabApiError(0, result.errors.map((e) => e.message).join("; "));
81
+ }
82
+ if (!result.data) {
83
+ throw new GitLabApiError(0, "GraphQL response missing data");
84
+ }
85
+ return result.data;
86
+ }
87
+
88
+ // src/gitlab/project.ts
89
+ import fs from "fs/promises";
90
+ import path from "path";
91
+ async function detectProjectPath(cwd, instanceUrl) {
92
+ const instance = new URL(instanceUrl);
93
+ const instanceHost = instance.host;
94
+ const instanceBasePath = instance.pathname.replace(/\/$/, "");
95
+ let current = cwd;
96
+ for (; ; ) {
97
+ try {
98
+ const config = await readGitConfig(current);
99
+ const url = extractOriginUrl(config);
100
+ if (!url) return void 0;
101
+ const remote = parseRemoteUrl(url);
102
+ if (!remote || remote.host !== instanceHost) return void 0;
103
+ return normalizeProjectPath(remote.path, instanceBasePath);
104
+ } catch {
105
+ const parent = path.dirname(current);
106
+ if (parent === current) return void 0;
107
+ current = parent;
108
+ }
109
+ }
110
+ }
111
+ async function fetchProjectDetails(client, projectPath) {
112
+ const encoded = encodeURIComponent(projectPath);
113
+ const data = await get(client, `projects/${encoded}`);
114
+ if (!data.id || !data.namespace?.id) {
115
+ throw new Error(`Project ${projectPath}: missing id or namespace`);
116
+ }
117
+ return {
118
+ projectId: String(data.id),
119
+ namespaceId: String(data.namespace.id)
120
+ };
121
+ }
122
+ async function resolveRootNamespaceId(client, namespaceId) {
123
+ let currentId = namespaceId;
124
+ for (let depth = 0; depth < 20; depth++) {
125
+ let ns;
126
+ try {
127
+ ns = await get(client, `namespaces/${currentId}`);
128
+ } catch {
129
+ break;
130
+ }
131
+ if (!ns.parent_id) {
132
+ currentId = String(ns.id ?? currentId);
133
+ break;
134
+ }
135
+ currentId = String(ns.parent_id);
136
+ }
137
+ return `gid://gitlab/Group/${currentId}`;
138
+ }
139
+ async function readGitConfig(cwd) {
140
+ const gitPath = path.join(cwd, ".git");
141
+ const stat = await fs.stat(gitPath);
142
+ if (stat.isDirectory()) {
143
+ return fs.readFile(path.join(gitPath, "config"), "utf8");
144
+ }
145
+ const content = await fs.readFile(gitPath, "utf8");
146
+ const match = /^gitdir:\s*(.+)$/m.exec(content);
147
+ if (!match) throw new Error("Invalid .git file");
148
+ const gitdir = match[1].trim();
149
+ const resolved = path.isAbsolute(gitdir) ? gitdir : path.join(cwd, gitdir);
150
+ return fs.readFile(path.join(resolved, "config"), "utf8");
151
+ }
152
+ function extractOriginUrl(config) {
153
+ const lines = config.split("\n");
154
+ let inOrigin = false;
155
+ let originUrl;
156
+ let firstUrl;
157
+ for (const line of lines) {
158
+ const trimmed = line.trim();
159
+ const section = /^\[remote\s+"([^"]+)"\]$/.exec(trimmed);
160
+ if (section) {
161
+ inOrigin = section[1] === "origin";
162
+ continue;
163
+ }
164
+ const urlMatch = /^url\s*=\s*(.+)$/.exec(trimmed);
165
+ if (urlMatch) {
166
+ const value = urlMatch[1].trim();
167
+ if (!firstUrl) firstUrl = value;
168
+ if (inOrigin) originUrl = value;
169
+ }
170
+ }
171
+ return originUrl ?? firstUrl;
172
+ }
173
+ function parseRemoteUrl(url) {
174
+ if (url.startsWith("git@")) {
175
+ const match = /^git@([^:]+):(.+)$/.exec(url);
176
+ return match ? { host: match[1], path: match[2] } : void 0;
177
+ }
178
+ try {
179
+ const parsed = new URL(url);
180
+ return { host: parsed.host, path: parsed.pathname.replace(/^\//, "") };
181
+ } catch {
182
+ return void 0;
183
+ }
184
+ }
185
+ function normalizeProjectPath(remotePath, instanceBasePath) {
186
+ let p = remotePath;
187
+ if (instanceBasePath && instanceBasePath !== "/") {
188
+ const base = instanceBasePath.replace(/^\//, "") + "/";
189
+ if (p.startsWith(base)) p = p.slice(base.length);
190
+ }
191
+ if (p.endsWith(".git")) p = p.slice(0, -4);
192
+ return p.length > 0 ? p : void 0;
193
+ }
194
+
195
+ // src/gitlab/models.ts
196
+ var CachePayloadSchema = z.object({
197
+ cachedAt: z.string(),
198
+ instanceUrl: z.string(),
199
+ models: z.array(
200
+ z.object({
201
+ id: z.string(),
202
+ name: z.string()
203
+ })
204
+ ).min(1)
205
+ });
206
+ var QUERY = `query lsp_aiChatAvailableModels($rootNamespaceId: GroupID!) {
207
+ aiChatAvailableModels(rootNamespaceId: $rootNamespaceId) {
208
+ defaultModel { name ref }
209
+ selectableModels { name ref }
210
+ pinnedModel { name ref }
211
+ }
212
+ }`;
213
+ async function loadAvailableModels(instanceUrl, token, cwd) {
214
+ const cachePath = getCachePath(instanceUrl, cwd);
215
+ const cached = await readCache(cachePath);
216
+ if (cached && !isStale(cached)) {
217
+ return cached.models;
218
+ }
219
+ if (token) {
220
+ try {
221
+ const models = await fetchModelsFromApi({ instanceUrl, token }, cwd);
222
+ if (models.length > 0) {
223
+ await writeCache(cachePath, { cachedAt: (/* @__PURE__ */ new Date()).toISOString(), instanceUrl, models });
224
+ return models;
225
+ }
226
+ } catch {
227
+ }
228
+ }
229
+ if (cached) {
230
+ return cached.models;
231
+ }
232
+ return [{ id: DEFAULT_MODEL_ID, name: DEFAULT_MODEL_ID }];
233
+ }
234
+ async function fetchModelsFromApi(client, cwd) {
235
+ const projectPath = await detectProjectPath(cwd, client.instanceUrl);
236
+ if (!projectPath) return [];
237
+ const project = await fetchProjectDetails(client, projectPath);
238
+ const rootNamespaceId = await resolveRootNamespaceId(client, project.namespaceId);
239
+ const data = await graphql(client, QUERY, { rootNamespaceId });
240
+ const available = data.aiChatAvailableModels;
241
+ if (!available) return [];
242
+ const seen = /* @__PURE__ */ new Set();
243
+ const models = [];
244
+ function add(entry2) {
245
+ if (!entry2?.ref || seen.has(entry2.ref)) return;
246
+ seen.add(entry2.ref);
247
+ models.push({ id: entry2.ref, name: entry2.name || entry2.ref });
248
+ }
249
+ add(available.defaultModel);
250
+ add(available.pinnedModel);
251
+ for (const m of available.selectableModels ?? []) add(m);
252
+ return models;
253
+ }
254
+ function getCachePath(instanceUrl, cwd) {
255
+ const key = `${instanceUrl}::${cwd}`;
256
+ const hash = crypto.createHash("sha256").update(key).digest("hex").slice(0, 12);
257
+ const dir = process.env.XDG_CACHE_HOME?.trim() ? path2.join(process.env.XDG_CACHE_HOME, "opencode") : path2.join(os.homedir(), ".cache", "opencode");
258
+ return path2.join(dir, `gitlab-duo-models-${hash}.json`);
259
+ }
260
+ function isStale(payload) {
261
+ const age = Date.now() - Date.parse(payload.cachedAt);
262
+ return age > CACHE_TTL_MS;
263
+ }
264
+ async function readCache(cachePath) {
265
+ try {
266
+ const raw = await fs2.readFile(cachePath, "utf8");
267
+ const parsed = CachePayloadSchema.parse(JSON.parse(raw));
268
+ return parsed;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+ async function writeCache(cachePath, payload) {
274
+ try {
275
+ await fs2.mkdir(path2.dirname(cachePath), { recursive: true });
276
+ await fs2.writeFile(cachePath, JSON.stringify(payload, null, 2), "utf8");
277
+ } catch {
278
+ }
279
+ }
280
+
281
+ // src/utils/url.ts
282
+ function text(value) {
283
+ if (typeof value !== "string") return void 0;
284
+ const trimmed = value.trim();
285
+ return trimmed.length > 0 ? trimmed : void 0;
286
+ }
287
+ function envInstanceUrl() {
288
+ return process.env.GITLAB_INSTANCE_URL ?? process.env.GITLAB_URL ?? process.env.GITLAB_BASE_URL;
289
+ }
290
+ function normalizeInstanceUrl(value) {
291
+ const raw = text(value) ?? DEFAULT_INSTANCE_URL;
292
+ const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
293
+ try {
294
+ const url = new URL(withProtocol);
295
+ return `${url.protocol}//${url.host}`;
296
+ } catch {
297
+ return DEFAULT_INSTANCE_URL;
298
+ }
299
+ }
300
+
301
+ // src/gitlab/resolve-credentials.ts
302
+ function resolveCredentials(options = {}) {
303
+ const instanceUrl = normalizeInstanceUrl(options.instanceUrl ?? envInstanceUrl());
304
+ const token = firstNonEmptyString(options.apiKey, options.token) ?? envToken() ?? "";
305
+ return { instanceUrl, token };
306
+ }
307
+ function envToken() {
308
+ return process.env.GITLAB_TOKEN ?? process.env.GITLAB_OAUTH_TOKEN;
309
+ }
310
+ function firstNonEmptyString(...values) {
311
+ for (const v of values) {
312
+ if (typeof v === "string" && v.trim().length > 0) return v.trim();
313
+ }
314
+ return void 0;
315
+ }
316
+
317
+ // src/plugin/config.ts
318
+ async function applyRuntimeConfig(config, directory) {
319
+ config.provider ??= {};
320
+ const current = config.provider[PROVIDER_ID] ?? {};
321
+ const options = current.options ?? {};
322
+ const { instanceUrl, token } = resolveCredentials(options);
323
+ const available = await loadAvailableModels(instanceUrl, token, directory);
324
+ const modelIds = available.map((m) => m.id);
325
+ const models = toModelsConfig(available);
326
+ config.provider[PROVIDER_ID] = {
327
+ ...current,
328
+ npm: "opencode-gitlab-duo-agentic-custom-tools",
329
+ whitelist: modelIds,
330
+ options: {
331
+ ...options,
332
+ instanceUrl
333
+ },
334
+ models: {
335
+ ...current.models ?? {},
336
+ ...models
337
+ }
338
+ };
339
+ }
340
+ function toModelsConfig(available) {
341
+ const out = {};
342
+ for (const m of available) {
343
+ out[m.id] = { id: m.id, name: m.name };
344
+ }
345
+ return out;
346
+ }
347
+
348
+ // src/plugin/hooks.ts
349
+ async function createPluginHooks(input) {
350
+ return {
351
+ tool: {
352
+ todoread: tool({
353
+ description: "Use this tool to read your todo list",
354
+ args: {},
355
+ async execute(_args, ctx) {
356
+ await ctx.ask({
357
+ permission: "todoread",
358
+ patterns: ["*"],
359
+ always: ["*"],
360
+ metadata: {}
361
+ });
362
+ const url = new URL(`/session/${encodeURIComponent(ctx.sessionID)}/todo`, input.serverUrl);
363
+ const response = await fetch(url);
364
+ if (!response.ok) {
365
+ throw new Error(`Failed to read todos: ${response.status} ${response.statusText}`);
366
+ }
367
+ const payload = await response.json();
368
+ if (Array.isArray(payload)) {
369
+ return JSON.stringify(payload, null, 2);
370
+ }
371
+ return "[]";
372
+ }
373
+ })
374
+ },
375
+ config: async (config) => applyRuntimeConfig(config, input.directory),
376
+ "chat.message": async ({ sessionID }, { parts }) => {
377
+ const text2 = parts.filter((p) => p.type === "text" && !("synthetic" in p && p.synthetic)).map((p) => "text" in p ? p.text : "").join(" ").trim();
378
+ if (!text2) return;
379
+ const title = text2.length > 100 ? text2.slice(0, 97) + "..." : text2;
380
+ const url = new URL(`/session/${encodeURIComponent(sessionID)}`, input.serverUrl);
381
+ await fetch(url, {
382
+ method: "PATCH",
383
+ headers: { "content-type": "application/json" },
384
+ body: JSON.stringify({ title })
385
+ }).catch(() => {
386
+ });
387
+ },
388
+ "chat.params": async (context, output) => {
389
+ if (!isGitLabProvider(context.model)) return;
390
+ if (isUtilityAgent(context.agent)) return;
391
+ output.options = {
392
+ ...output.options,
393
+ workflowSessionID: context.sessionID
394
+ };
395
+ },
396
+ "chat.headers": async (context, output) => {
397
+ if (!isGitLabProvider(context.model)) return;
398
+ if (isUtilityAgent(context.agent)) return;
399
+ output.headers = {
400
+ ...output.headers,
401
+ "x-opencode-session": context.sessionID
402
+ };
403
+ }
404
+ };
405
+ }
406
+ var UTILITY_AGENTS = /* @__PURE__ */ new Set(["title", "compaction"]);
407
+ function isUtilityAgent(agent) {
408
+ const name = typeof agent === "string" ? agent : agent.name;
409
+ return UTILITY_AGENTS.has(name);
410
+ }
411
+ function isGitLabProvider(model) {
412
+ if (model.api?.npm === "opencode-gitlab-duo-agentic-custom-tools") return true;
413
+ if (model.providerID === "gitlab" && model.api?.npm !== "@gitlab/gitlab-ai-provider") return true;
414
+ return model.providerID.toLowerCase().includes("gitlab-duo");
415
+ }
416
+
417
+ // src/provider/index.ts
418
+ import { NoSuchModelError } from "@ai-sdk/provider";
419
+
420
+ // src/provider/duo-workflow-model.ts
421
+ import { randomUUID as randomUUID3 } from "crypto";
422
+
423
+ // src/workflow/session.ts
424
+ import { randomUUID as randomUUID2 } from "crypto";
425
+
426
+ // src/utils/async-queue.ts
427
+ var AsyncQueue = class {
428
+ #values = [];
429
+ #waiters = [];
430
+ #closed = false;
431
+ push(value) {
432
+ if (this.#closed) return;
433
+ const waiter = this.#waiters.shift();
434
+ if (waiter) {
435
+ waiter(value);
436
+ return;
437
+ }
438
+ this.#values.push(value);
439
+ }
440
+ /** Returns null when closed and no buffered values remain. */
441
+ shift() {
442
+ const value = this.#values.shift();
443
+ if (value !== void 0) return Promise.resolve(value);
444
+ if (this.#closed) return Promise.resolve(null);
445
+ return new Promise((resolve) => this.#waiters.push(resolve));
446
+ }
447
+ close() {
448
+ this.#closed = true;
449
+ for (const waiter of this.#waiters) {
450
+ waiter(null);
451
+ }
452
+ this.#waiters = [];
453
+ }
454
+ };
455
+
456
+ // src/workflow/checkpoint.ts
457
+ import { randomUUID } from "crypto";
458
+ function createCheckpointState() {
459
+ return {
460
+ uiChatLog: [],
461
+ processedRequestIndices: /* @__PURE__ */ new Set()
462
+ };
463
+ }
464
+ function extractAgentTextDeltas(checkpoint, state) {
465
+ const next = parseCheckpoint(checkpoint);
466
+ const out = [];
467
+ for (let i = 0; i < next.length; i++) {
468
+ const item = next[i];
469
+ if (item.message_type !== "agent") continue;
470
+ const previous = state.uiChatLog[i];
471
+ if (!previous || previous.message_type !== "agent") {
472
+ if (item.content) out.push(item.content);
473
+ continue;
474
+ }
475
+ if (item.content === previous.content) continue;
476
+ if (item.content.startsWith(previous.content)) {
477
+ const delta = item.content.slice(previous.content.length);
478
+ if (delta) out.push(delta);
479
+ continue;
480
+ }
481
+ if (item.content) out.push(item.content);
482
+ }
483
+ state.uiChatLog = next;
484
+ return out;
485
+ }
486
+ function parseCheckpoint(raw) {
487
+ if (!raw) return [];
488
+ try {
489
+ const parsed = JSON.parse(raw);
490
+ const log = parsed.channel_values?.ui_chat_log;
491
+ if (!Array.isArray(log)) return [];
492
+ return log.filter(isUiChatLogEntry);
493
+ } catch {
494
+ return [];
495
+ }
496
+ }
497
+ function isUiChatLogEntry(value) {
498
+ if (!value || typeof value !== "object") return false;
499
+ const item = value;
500
+ if (typeof item.message_type !== "string") return false;
501
+ if (typeof item.content !== "string") return false;
502
+ return item.message_type === "user" || item.message_type === "agent" || item.message_type === "tool" || item.message_type === "request";
503
+ }
504
+
505
+ // src/workflow/token-service.ts
506
+ var WorkflowTokenService = class {
507
+ #client;
508
+ #cache = /* @__PURE__ */ new Map();
509
+ constructor(client) {
510
+ this.#client = client;
511
+ }
512
+ clear() {
513
+ this.#cache.clear();
514
+ }
515
+ async get(rootNamespaceId) {
516
+ const key = rootNamespaceId ?? "";
517
+ const cached = this.#cache.get(key);
518
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
519
+ try {
520
+ const value = await post(
521
+ this.#client,
522
+ "ai/duo_workflows/direct_access",
523
+ rootNamespaceId ? {
524
+ workflow_definition: WORKFLOW_DEFINITION,
525
+ root_namespace_id: rootNamespaceId
526
+ } : {
527
+ workflow_definition: WORKFLOW_DEFINITION
528
+ }
529
+ );
530
+ const expiresAt = readExpiry(value);
531
+ this.#cache.set(key, { value, expiresAt });
532
+ return value;
533
+ } catch {
534
+ return null;
535
+ }
536
+ }
537
+ };
538
+ function readExpiry(value) {
539
+ const workflowExpiry = typeof value.duo_workflow_service?.token_expires_at === "number" ? value.duo_workflow_service.token_expires_at * 1e3 : Number.POSITIVE_INFINITY;
540
+ const railsExpiry = typeof value.gitlab_rails?.token_expires_at === "string" ? Date.parse(value.gitlab_rails.token_expires_at) : Number.POSITIVE_INFINITY;
541
+ const expiry = Math.min(workflowExpiry, railsExpiry);
542
+ if (!Number.isFinite(expiry)) return Date.now() + 5 * 60 * 1e3;
543
+ return Math.max(Date.now() + 1e3, expiry - WORKFLOW_TOKEN_EXPIRY_BUFFER_MS);
544
+ }
545
+
546
+ // src/workflow/types.ts
547
+ var WORKFLOW_STATUS = {
548
+ CREATED: "CREATED",
549
+ RUNNING: "RUNNING",
550
+ FINISHED: "FINISHED",
551
+ FAILED: "FAILED",
552
+ STOPPED: "STOPPED",
553
+ INPUT_REQUIRED: "INPUT_REQUIRED",
554
+ PLAN_APPROVAL_REQUIRED: "PLAN_APPROVAL_REQUIRED",
555
+ TOOL_CALL_APPROVAL_REQUIRED: "TOOL_CALL_APPROVAL_REQUIRED"
556
+ };
557
+ function isCheckpointAction(action) {
558
+ return "newCheckpoint" in action && action.newCheckpoint != null;
559
+ }
560
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
561
+ WORKFLOW_STATUS.FINISHED,
562
+ WORKFLOW_STATUS.FAILED,
563
+ WORKFLOW_STATUS.STOPPED
564
+ ]);
565
+ function isTerminal(status) {
566
+ return TERMINAL_STATUSES.has(status);
567
+ }
568
+ var TURN_BOUNDARY_STATUSES = /* @__PURE__ */ new Set([
569
+ WORKFLOW_STATUS.INPUT_REQUIRED,
570
+ WORKFLOW_STATUS.PLAN_APPROVAL_REQUIRED
571
+ ]);
572
+ function isTurnBoundary(status) {
573
+ return TURN_BOUNDARY_STATUSES.has(status);
574
+ }
575
+ function isToolApproval(status) {
576
+ return status === WORKFLOW_STATUS.TOOL_CALL_APPROVAL_REQUIRED;
577
+ }
578
+ function isTurnComplete(status) {
579
+ return isTerminal(status) || isTurnBoundary(status);
580
+ }
581
+
582
+ // src/workflow/websocket-client.ts
583
+ import WebSocket from "isomorphic-ws";
584
+ var WorkflowWebSocketClient = class {
585
+ #socket = null;
586
+ #heartbeat;
587
+ #keepalive;
588
+ #callbacks;
589
+ constructor(callbacks) {
590
+ this.#callbacks = callbacks;
591
+ }
592
+ async connect(url, headers) {
593
+ const socket = new WebSocket(url, { headers });
594
+ this.#socket = socket;
595
+ await new Promise((resolve, reject) => {
596
+ const timeout = setTimeout(() => {
597
+ cleanup();
598
+ socket.close(1e3);
599
+ reject(new Error(`WebSocket connection timeout after ${WORKFLOW_CONNECT_TIMEOUT_MS}ms`));
600
+ }, WORKFLOW_CONNECT_TIMEOUT_MS);
601
+ const cleanup = () => {
602
+ clearTimeout(timeout);
603
+ socket.off("open", onOpen);
604
+ socket.off("error", onError);
605
+ };
606
+ const onOpen = () => {
607
+ cleanup();
608
+ resolve();
609
+ };
610
+ const onError = (error) => {
611
+ cleanup();
612
+ reject(error);
613
+ };
614
+ socket.once("open", onOpen);
615
+ socket.once("error", onError);
616
+ });
617
+ socket.on("message", (data) => {
618
+ try {
619
+ const payload = decodeSocketMessage(data);
620
+ if (!payload) return;
621
+ const parsed = JSON.parse(payload);
622
+ this.#callbacks.action(parsed);
623
+ } catch (error) {
624
+ const next = error instanceof Error ? error : new Error(String(error));
625
+ this.#callbacks.error(next);
626
+ }
627
+ });
628
+ socket.on("error", (error) => {
629
+ this.#callbacks.error(error instanceof Error ? error : new Error(String(error)));
630
+ });
631
+ socket.on("close", (code, reason) => {
632
+ this.#stopIntervals();
633
+ this.#callbacks.close(code, reason?.toString("utf8") ?? "");
634
+ });
635
+ this.#heartbeat = setInterval(() => {
636
+ this.send({ heartbeat: { timestamp: Date.now() } });
637
+ }, WORKFLOW_HEARTBEAT_INTERVAL_MS);
638
+ this.#keepalive = setInterval(() => {
639
+ if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) return;
640
+ this.#socket.ping(Buffer.from(String(Date.now())));
641
+ }, WORKFLOW_KEEPALIVE_INTERVAL_MS);
642
+ }
643
+ send(event) {
644
+ if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) return false;
645
+ this.#socket.send(JSON.stringify(event));
646
+ return true;
647
+ }
648
+ close() {
649
+ this.#stopIntervals();
650
+ if (!this.#socket) return;
651
+ this.#socket.close(1e3);
652
+ this.#socket = null;
653
+ }
654
+ #stopIntervals() {
655
+ if (this.#heartbeat) {
656
+ clearInterval(this.#heartbeat);
657
+ this.#heartbeat = void 0;
658
+ }
659
+ if (this.#keepalive) {
660
+ clearInterval(this.#keepalive);
661
+ this.#keepalive = void 0;
662
+ }
663
+ }
664
+ };
665
+ function decodeSocketMessage(data) {
666
+ if (typeof data === "string") return data;
667
+ if (Buffer.isBuffer(data)) return data.toString("utf8");
668
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
669
+ if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
670
+ return void 0;
671
+ }
672
+
673
+ // src/workflow/action-mapper.ts
674
+ function mapActionToToolRequest(action) {
675
+ const requestId = action.requestID;
676
+ if (!requestId) return null;
677
+ if (action.runMCPTool) {
678
+ let parsedArgs;
679
+ if (typeof action.runMCPTool.args === "string") {
680
+ try {
681
+ parsedArgs = JSON.parse(action.runMCPTool.args);
682
+ } catch {
683
+ parsedArgs = {};
684
+ }
685
+ } else {
686
+ parsedArgs = {};
687
+ }
688
+ return { requestId, toolName: action.runMCPTool.name, args: parsedArgs };
689
+ }
690
+ if (action.runReadFile) {
691
+ return {
692
+ requestId,
693
+ toolName: "read_file",
694
+ args: {
695
+ file_path: action.runReadFile.filepath,
696
+ offset: action.runReadFile.offset,
697
+ limit: action.runReadFile.limit
698
+ }
699
+ };
700
+ }
701
+ if (action.runReadFiles) {
702
+ return {
703
+ requestId,
704
+ toolName: "read_files",
705
+ args: { file_paths: action.runReadFiles.filepaths ?? [] }
706
+ };
707
+ }
708
+ if (action.runWriteFile) {
709
+ return {
710
+ requestId,
711
+ toolName: "create_file_with_contents",
712
+ args: {
713
+ file_path: action.runWriteFile.filepath,
714
+ contents: action.runWriteFile.contents
715
+ }
716
+ };
717
+ }
718
+ if (action.runEditFile) {
719
+ return {
720
+ requestId,
721
+ toolName: "edit_file",
722
+ args: {
723
+ file_path: action.runEditFile.filepath,
724
+ old_str: action.runEditFile.oldString,
725
+ new_str: action.runEditFile.newString
726
+ }
727
+ };
728
+ }
729
+ if (action.findFiles) {
730
+ return {
731
+ requestId,
732
+ toolName: "find_files",
733
+ args: { name_pattern: action.findFiles.name_pattern }
734
+ };
735
+ }
736
+ if (action.listDirectory) {
737
+ return {
738
+ requestId,
739
+ toolName: "list_dir",
740
+ args: { directory: action.listDirectory.directory }
741
+ };
742
+ }
743
+ if (action.grep) {
744
+ const args = { pattern: action.grep.pattern };
745
+ if (action.grep.search_directory) args.search_directory = action.grep.search_directory;
746
+ if (action.grep.case_insensitive !== void 0) args.case_insensitive = action.grep.case_insensitive;
747
+ return { requestId, toolName: "grep", args };
748
+ }
749
+ if (action.mkdir) {
750
+ return {
751
+ requestId,
752
+ toolName: "mkdir",
753
+ args: { directory_path: action.mkdir.directory_path }
754
+ };
755
+ }
756
+ if (action.runShellCommand) {
757
+ return {
758
+ requestId,
759
+ toolName: "shell_command",
760
+ args: { command: action.runShellCommand.command }
761
+ };
762
+ }
763
+ if (action.runCommand) {
764
+ return {
765
+ requestId,
766
+ toolName: "run_command",
767
+ args: {
768
+ program: action.runCommand.program,
769
+ flags: action.runCommand.flags,
770
+ arguments: action.runCommand.arguments
771
+ }
772
+ };
773
+ }
774
+ if (action.runGitCommand) {
775
+ return {
776
+ requestId,
777
+ toolName: "run_git_command",
778
+ args: {
779
+ repository_url: action.runGitCommand.repository_url ?? "",
780
+ command: action.runGitCommand.command,
781
+ args: action.runGitCommand.arguments
782
+ }
783
+ };
784
+ }
785
+ if (action.runHTTPRequest) {
786
+ return {
787
+ requestId,
788
+ toolName: "gitlab_api_request",
789
+ args: {
790
+ method: action.runHTTPRequest.method,
791
+ path: action.runHTTPRequest.path,
792
+ body: action.runHTTPRequest.body
793
+ }
794
+ };
795
+ }
796
+ return null;
797
+ }
798
+
799
+ // src/utils/debug-log.ts
800
+ import { appendFileSync } from "fs";
801
+ var LOG_FILE = "/tmp/duo-workflow-debug.log";
802
+ function dlog(msg) {
803
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
804
+ appendFileSync(LOG_FILE, `[${ts}] ${msg}
805
+ `);
806
+ }
807
+
808
+ // src/workflow/session.ts
809
+ var BLOCKED_HTTP_REQUEST_ERROR = "gitlab_api_request is disabled by client policy";
810
+ var BLOCKED_GIT_COMMAND_ERROR = "run_git_command is disabled by client policy";
811
+ var WorkflowSession = class {
812
+ #client;
813
+ #tokenService;
814
+ #modelId;
815
+ #cwd;
816
+ #workflowId;
817
+ #projectPath;
818
+ #rootNamespaceId;
819
+ #checkpoint = createCheckpointState();
820
+ #toolsConfig;
821
+ #socket;
822
+ #queue;
823
+ #startRequestSent = false;
824
+ #pendingApproval = false;
825
+ #resumed = false;
826
+ #onWorkflowCreated;
827
+ constructor(client, modelId, cwd, options) {
828
+ this.#client = client;
829
+ this.#tokenService = new WorkflowTokenService(client);
830
+ this.#modelId = modelId;
831
+ this.#cwd = cwd;
832
+ if (options?.existingWorkflowId) {
833
+ this.#workflowId = options.existingWorkflowId;
834
+ this.#resumed = true;
835
+ }
836
+ this.#onWorkflowCreated = options?.onWorkflowCreated;
837
+ }
838
+ /**
839
+ * Opt-in: override the server-side system prompt and/or register MCP tools.
840
+ */
841
+ setToolsConfig(config) {
842
+ this.#toolsConfig = config;
843
+ }
844
+ get workflowId() {
845
+ return this.#workflowId;
846
+ }
847
+ get hasStarted() {
848
+ return this.#startRequestSent;
849
+ }
850
+ reset() {
851
+ this.#workflowId = void 0;
852
+ this.#checkpoint = createCheckpointState();
853
+ this.#tokenService.clear();
854
+ this.#closeConnection();
855
+ this.#pendingApproval = false;
856
+ this.#resumed = false;
857
+ this.#startRequestSent = false;
858
+ }
859
+ // ---------------------------------------------------------------------------
860
+ // Connection lifecycle (persistent)
861
+ // ---------------------------------------------------------------------------
862
+ async ensureConnected(goal) {
863
+ if (this.#socket && this.#queue) return;
864
+ if (!this.#workflowId) {
865
+ this.#workflowId = await this.#createWorkflow(goal);
866
+ }
867
+ const queue = new AsyncQueue();
868
+ this.#queue = queue;
869
+ await this.#connectSocket(queue);
870
+ }
871
+ /**
872
+ * Open a WebSocket and wire its callbacks to the given queue.
873
+ * Replaces any existing socket but does NOT create a new queue.
874
+ */
875
+ async #connectSocket(queue) {
876
+ await this.#tokenService.get(this.#rootNamespaceId);
877
+ const socket = new WorkflowWebSocketClient({
878
+ action: (action) => this.#handleAction(action, queue),
879
+ error: (error) => queue.push({ type: "error", message: error.message }),
880
+ close: (code, reason) => {
881
+ dlog(`ws-close: code=${code} reason=${reason} pendingApproval=${this.#pendingApproval}`);
882
+ this.#socket = void 0;
883
+ if (this.#pendingApproval) {
884
+ this.#pendingApproval = false;
885
+ this.#reconnectWithApproval(queue);
886
+ } else {
887
+ this.#queue = void 0;
888
+ queue.close();
889
+ }
890
+ }
891
+ });
892
+ const url = buildWebSocketUrl(this.#client.instanceUrl, this.#modelId);
893
+ await socket.connect(url, {
894
+ authorization: `Bearer ${this.#client.token}`,
895
+ origin: new URL(this.#client.instanceUrl).origin,
896
+ "x-request-id": randomUUID2(),
897
+ "x-gitlab-client-type": "node-websocket"
898
+ });
899
+ this.#socket = socket;
900
+ }
901
+ // ---------------------------------------------------------------------------
902
+ // Messaging
903
+ // ---------------------------------------------------------------------------
904
+ sendStartRequest(goal, additionalContext = []) {
905
+ if (!this.#socket || !this.#workflowId) throw new Error("Not connected");
906
+ const mcpTools = this.#toolsConfig?.mcpTools ?? [];
907
+ const preapprovedTools = mcpTools.map((t) => t.name);
908
+ this.#socket.send({
909
+ startRequest: {
910
+ workflowID: this.#workflowId,
911
+ clientVersion: WORKFLOW_CLIENT_VERSION,
912
+ workflowDefinition: WORKFLOW_DEFINITION,
913
+ goal,
914
+ workflowMetadata: JSON.stringify({
915
+ extended_logging: false
916
+ }),
917
+ clientCapabilities: ["shell_command"],
918
+ mcpTools,
919
+ additional_context: additionalContext,
920
+ preapproved_tools: preapprovedTools,
921
+ ...this.#toolsConfig?.flowConfig ? {
922
+ flowConfig: this.#toolsConfig.flowConfig,
923
+ flowConfigSchemaVersion: this.#toolsConfig.flowConfigSchemaVersion ?? "v1"
924
+ } : {}
925
+ }
926
+ });
927
+ this.#startRequestSent = true;
928
+ }
929
+ /**
930
+ * Send a tool result back to DWS on the existing connection.
931
+ */
932
+ sendToolResult(requestId, output, error) {
933
+ dlog(`sendToolResult: reqId=${requestId} output=${output.length}b error=${error ?? "none"} socket=${!!this.#socket}`);
934
+ if (!this.#socket) throw new Error("Not connected");
935
+ this.#socket.send({
936
+ actionResponse: {
937
+ requestID: requestId,
938
+ plainTextResponse: {
939
+ response: output,
940
+ error: error ?? ""
941
+ }
942
+ }
943
+ });
944
+ }
945
+ /**
946
+ * Send an HTTP response back to DWS on the existing connection.
947
+ * Used for gitlab_api_request which requires httpResponse (not plainTextResponse).
948
+ */
949
+ sendHttpResult(requestId, statusCode, headers, body, error) {
950
+ dlog(`sendHttpResult: reqId=${requestId} status=${statusCode} body=${body.length}b error=${error ?? "none"} socket=${!!this.#socket}`);
951
+ if (!this.#socket) throw new Error("Not connected");
952
+ this.#socket.send({
953
+ actionResponse: {
954
+ requestID: requestId,
955
+ httpResponse: {
956
+ statusCode,
957
+ headers,
958
+ body,
959
+ error: error ?? ""
960
+ }
961
+ }
962
+ });
963
+ }
964
+ /**
965
+ * Wait for the next event from the session.
966
+ * Returns null when the stream is closed (turn complete or connection lost).
967
+ */
968
+ async waitForEvent() {
969
+ if (!this.#queue) return null;
970
+ return this.#queue.shift();
971
+ }
972
+ /**
973
+ * Send an abort signal to DWS and close the connection.
974
+ */
975
+ abort() {
976
+ this.#socket?.send({ stopWorkflow: { reason: "ABORTED" } });
977
+ this.#closeConnection();
978
+ }
979
+ // ---------------------------------------------------------------------------
980
+ // Private: action handling
981
+ // ---------------------------------------------------------------------------
982
+ #handleAction(action, queue) {
983
+ if (isCheckpointAction(action)) {
984
+ const ckpt = action.newCheckpoint.checkpoint;
985
+ const status = action.newCheckpoint.status;
986
+ dlog(`checkpoint: status=${status} ckptLen=${ckpt.length}`);
987
+ const deltas = extractAgentTextDeltas(ckpt, this.#checkpoint);
988
+ if (this.#resumed) {
989
+ dlog(`checkpoint: RESUMED \u2014 discarding ${deltas.length} old deltas, fast-forwarding state`);
990
+ this.#resumed = false;
991
+ } else {
992
+ for (const delta of deltas) {
993
+ queue.push({ type: "text-delta", value: delta });
994
+ }
995
+ if (deltas.length > 0) {
996
+ dlog(`checkpoint: ${deltas.length} text deltas`);
997
+ }
998
+ }
999
+ if (isToolApproval(status)) {
1000
+ dlog(`checkpoint: TOOL_APPROVAL \u2192 pendingApproval=true (waiting for DWS close)`);
1001
+ this.#pendingApproval = true;
1002
+ return;
1003
+ }
1004
+ if (isTurnComplete(status)) {
1005
+ dlog(`checkpoint: turnComplete \u2192 close queue+connection`);
1006
+ queue.close();
1007
+ this.#closeConnection();
1008
+ }
1009
+ return;
1010
+ }
1011
+ const toolAction = action;
1012
+ if (toolAction.runHTTPRequest && toolAction.requestID) {
1013
+ dlog(`standalone: BLOCKED httpRequest ${toolAction.runHTTPRequest.method} ${toolAction.runHTTPRequest.path} reqId=${toolAction.requestID}`);
1014
+ this.sendHttpResult(toolAction.requestID, 403, {}, "", BLOCKED_HTTP_REQUEST_ERROR);
1015
+ return;
1016
+ }
1017
+ if (toolAction.runGitCommand && toolAction.requestID) {
1018
+ dlog(`standalone: BLOCKED runGitCommand ${toolAction.runGitCommand.command} reqId=${toolAction.requestID}`);
1019
+ this.sendToolResult(toolAction.requestID, "", BLOCKED_GIT_COMMAND_ERROR);
1020
+ return;
1021
+ }
1022
+ const mapped = mapActionToToolRequest(toolAction);
1023
+ if (mapped) {
1024
+ dlog(`standalone: ${mapped.toolName} reqId=${mapped.requestId} args=${JSON.stringify(mapped.args).slice(0, 200)}`);
1025
+ queue.push({
1026
+ type: "tool-request",
1027
+ requestId: mapped.requestId,
1028
+ toolName: mapped.toolName,
1029
+ args: mapped.args
1030
+ });
1031
+ } else {
1032
+ dlog(`standalone: UNMAPPED action keys=${Object.keys(action).join(",")}`);
1033
+ }
1034
+ }
1035
+ // ---------------------------------------------------------------------------
1036
+ // Private: connection management
1037
+ // ---------------------------------------------------------------------------
1038
+ /**
1039
+ * Auto-approve at DWS protocol level and reconnect.
1040
+ *
1041
+ * DWS closed the stream after TOOL_CALL_APPROVAL_REQUIRED. We open a new
1042
+ * WebSocket, send startRequest with approval, and wire it to the SAME queue
1043
+ * so Phase 3 in the model continues consuming events seamlessly.
1044
+ *
1045
+ * The actual tool execution still goes through OpenCode's permission system
1046
+ * when the standalone action arrives on the new stream.
1047
+ */
1048
+ #reconnectWithApproval(queue) {
1049
+ dlog(`reconnectWithApproval: starting (workflowId=${this.#workflowId})`);
1050
+ this.#connectSocket(queue).then(() => {
1051
+ if (!this.#socket || !this.#workflowId) {
1052
+ dlog(`reconnectWithApproval: FAILED no socket/workflowId`);
1053
+ queue.close();
1054
+ return;
1055
+ }
1056
+ const mcpTools = this.#toolsConfig?.mcpTools ?? [];
1057
+ dlog(`reconnectWithApproval: sending startRequest with approval (mcpTools=${mcpTools.length})`);
1058
+ this.#socket.send({
1059
+ startRequest: {
1060
+ workflowID: this.#workflowId,
1061
+ clientVersion: WORKFLOW_CLIENT_VERSION,
1062
+ workflowDefinition: WORKFLOW_DEFINITION,
1063
+ goal: "",
1064
+ workflowMetadata: JSON.stringify({ extended_logging: false }),
1065
+ clientCapabilities: ["shell_command"],
1066
+ mcpTools,
1067
+ additional_context: [],
1068
+ preapproved_tools: mcpTools.map((t) => t.name),
1069
+ approval: { approval: {} },
1070
+ ...this.#toolsConfig?.flowConfig ? {
1071
+ flowConfig: this.#toolsConfig.flowConfig,
1072
+ flowConfigSchemaVersion: this.#toolsConfig.flowConfigSchemaVersion ?? "v1"
1073
+ } : {}
1074
+ }
1075
+ });
1076
+ this.#startRequestSent = true;
1077
+ dlog(`reconnectWithApproval: approval sent, waiting for standalone actions`);
1078
+ }).catch((err) => {
1079
+ dlog(`reconnectWithApproval: ERROR ${err instanceof Error ? err.message : String(err)}`);
1080
+ this.#queue = void 0;
1081
+ queue.close();
1082
+ });
1083
+ }
1084
+ #closeConnection() {
1085
+ this.#pendingApproval = false;
1086
+ this.#socket?.close();
1087
+ this.#socket = void 0;
1088
+ this.#queue = void 0;
1089
+ this.#startRequestSent = false;
1090
+ }
1091
+ async #createWorkflow(goal) {
1092
+ await this.#loadProjectContext();
1093
+ const body = {
1094
+ goal,
1095
+ workflow_definition: WORKFLOW_DEFINITION,
1096
+ environment: WORKFLOW_ENVIRONMENT,
1097
+ allow_agent_to_request_user: true,
1098
+ ...this.#projectPath ? { project_id: this.#projectPath } : {}
1099
+ };
1100
+ const created = await post(this.#client, "ai/duo_workflows/workflows", body);
1101
+ if (created.id === void 0 || created.id === null) {
1102
+ const details = [created.message, created.error].filter(Boolean).join("; ");
1103
+ throw new Error(`failed to create workflow${details ? `: ${details}` : ""}`);
1104
+ }
1105
+ const workflowId = String(created.id);
1106
+ this.#onWorkflowCreated?.(workflowId);
1107
+ return workflowId;
1108
+ }
1109
+ async #loadProjectContext() {
1110
+ if (this.#projectPath !== void 0) return;
1111
+ const projectPath = await detectProjectPath(this.#cwd, this.#client.instanceUrl);
1112
+ this.#projectPath = projectPath;
1113
+ if (!projectPath) return;
1114
+ try {
1115
+ const project = await fetchProjectDetails(this.#client, projectPath);
1116
+ this.#rootNamespaceId = await resolveRootNamespaceId(this.#client, project.namespaceId);
1117
+ } catch {
1118
+ this.#rootNamespaceId = void 0;
1119
+ }
1120
+ }
1121
+ };
1122
+ function buildWebSocketUrl(instanceUrl, modelId) {
1123
+ const base = new URL(instanceUrl.endsWith("/") ? instanceUrl : `${instanceUrl}/`);
1124
+ const url = new URL("api/v4/ai/duo_workflows/ws", base);
1125
+ if (base.protocol === "https:") url.protocol = "wss:";
1126
+ if (base.protocol === "http:") url.protocol = "ws:";
1127
+ if (modelId) url.searchParams.set("user_selected_model_identifier", modelId);
1128
+ return url.toString();
1129
+ }
1130
+
1131
+ // src/provider/prompt.ts
1132
+ var SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
1133
+ var WRAPPED_USER_RE = /^<system-reminder>\s*The user sent the following message:\s*\n([\s\S]*?)\n\s*Please address this message and continue with your tasks\.\s*<\/system-reminder>$/;
1134
+ function extractGoal(prompt) {
1135
+ for (let i = prompt.length - 1; i >= 0; i--) {
1136
+ const message = prompt[i];
1137
+ if (message.role !== "user") continue;
1138
+ const content = Array.isArray(message.content) ? message.content : [];
1139
+ const text2 = content.filter((part) => part.type === "text").map((part) => stripSystemReminders(part.text)).filter(Boolean).join("\n").trim();
1140
+ if (text2) return text2;
1141
+ }
1142
+ return "";
1143
+ }
1144
+ function stripSystemReminders(value) {
1145
+ return value.replace(SYSTEM_REMINDER_RE, (block) => {
1146
+ const wrapped = WRAPPED_USER_RE.exec(block);
1147
+ return wrapped?.[1]?.trim() ?? "";
1148
+ }).trim();
1149
+ }
1150
+
1151
+ // src/provider/prompt-utils.ts
1152
+ function extractToolResults(prompt) {
1153
+ if (!Array.isArray(prompt)) return [];
1154
+ const results = [];
1155
+ for (const message of prompt) {
1156
+ const content = message.content;
1157
+ if (!Array.isArray(content)) continue;
1158
+ for (const part of content) {
1159
+ const p = part;
1160
+ if (p.type === "tool-result") {
1161
+ const toolCallId = String(p.toolCallId ?? "");
1162
+ const toolName = String(p.toolName ?? "");
1163
+ if (!toolCallId) continue;
1164
+ const { output, error } = parseToolResultOutput(p);
1165
+ const finalError = error ?? asString(p.error) ?? asString(p.errorText);
1166
+ results.push({ toolCallId, toolName, output, error: finalError });
1167
+ }
1168
+ if (p.type === "tool-error") {
1169
+ const toolCallId = String(p.toolCallId ?? "");
1170
+ const toolName = String(p.toolName ?? "");
1171
+ const errorValue = p.error ?? p.errorText ?? p.message;
1172
+ const error = asString(errorValue) ?? String(errorValue ?? "");
1173
+ results.push({ toolCallId, toolName, output: "", error });
1174
+ }
1175
+ }
1176
+ }
1177
+ return results;
1178
+ }
1179
+ function asString(value) {
1180
+ return typeof value === "string" ? value : void 0;
1181
+ }
1182
+ function parseToolResultOutput(part) {
1183
+ const outputField = part.output;
1184
+ const resultField = part.result;
1185
+ if (isPlainObject(outputField) && "type" in outputField) {
1186
+ const outputType = String(outputField.type);
1187
+ const outputValue = outputField.value;
1188
+ if (outputType === "text" || outputType === "json") {
1189
+ return { output: typeof outputValue === "string" ? outputValue : JSON.stringify(outputValue ?? "") };
1190
+ }
1191
+ if (outputType === "error-text" || outputType === "error-json") {
1192
+ return { output: "", error: typeof outputValue === "string" ? outputValue : JSON.stringify(outputValue ?? "") };
1193
+ }
1194
+ if (outputType === "content" && Array.isArray(outputValue)) {
1195
+ const text2 = outputValue.filter((v) => v.type === "text").map((v) => String(v.text ?? "")).join("\n");
1196
+ return { output: text2 };
1197
+ }
1198
+ }
1199
+ if (outputField !== void 0) {
1200
+ return { output: typeof outputField === "string" ? outputField : JSON.stringify(outputField) };
1201
+ }
1202
+ if (resultField !== void 0) {
1203
+ const output = typeof resultField === "string" ? resultField : JSON.stringify(resultField);
1204
+ const error = isPlainObject(resultField) ? asString(resultField.error) : void 0;
1205
+ return { output, error };
1206
+ }
1207
+ return { output: "" };
1208
+ }
1209
+ function extractSystemPrompt(prompt) {
1210
+ if (!Array.isArray(prompt)) return null;
1211
+ const parts = [];
1212
+ for (const message of prompt) {
1213
+ const msg = message;
1214
+ if (msg.role === "system" && typeof msg.content === "string" && msg.content.trim()) {
1215
+ parts.push(msg.content);
1216
+ }
1217
+ }
1218
+ return parts.length > 0 ? parts.join("\n") : null;
1219
+ }
1220
+ function sanitizeSystemPrompt(prompt) {
1221
+ let result = prompt;
1222
+ result = result.replace(/^You are [Oo]pen[Cc]ode[,.].*$/gm, "");
1223
+ result = result.replace(/^Your name is opencode\s*$/gm, "");
1224
+ result = result.replace(
1225
+ /If the user asks for help or wants to give feedback[\s\S]*?https:\/\/github\.com\/anomalyco\/opencode\s*/g,
1226
+ ""
1227
+ );
1228
+ result = result.replace(
1229
+ /When the user directly asks about OpenCode[\s\S]*?https:\/\/opencode\.ai\/docs\s*/g,
1230
+ ""
1231
+ );
1232
+ result = result.replace(/https:\/\/github\.com\/anomalyco\/opencode\S*/g, "");
1233
+ result = result.replace(/https:\/\/opencode\.ai\S*/g, "");
1234
+ result = result.replace(/\bOpenCode\b/g, "GitLab Duo");
1235
+ result = result.replace(/\bopencode\b/g, "GitLab Duo");
1236
+ result = result.replace(/The exact model ID is GitLab Duo\//g, "The exact model ID is ");
1237
+ result = result.replace(/\n{3,}/g, "\n\n");
1238
+ return result.trim();
1239
+ }
1240
+ function extractAgentReminders(prompt) {
1241
+ if (!Array.isArray(prompt)) return [];
1242
+ let textParts = [];
1243
+ for (let i = prompt.length - 1; i >= 0; i--) {
1244
+ const message = prompt[i];
1245
+ if (message?.role !== "user" || !Array.isArray(message.content)) continue;
1246
+ textParts = message.content.filter((p) => p.type === "text");
1247
+ if (textParts.length > 0) break;
1248
+ }
1249
+ if (textParts.length === 0) return [];
1250
+ const reminders = [];
1251
+ for (const part of textParts) {
1252
+ if (!part.text) continue;
1253
+ const text2 = String(part.text);
1254
+ if (part.synthetic) {
1255
+ const trimmed = text2.trim();
1256
+ if (trimmed.length > 0) reminders.push(trimmed);
1257
+ continue;
1258
+ }
1259
+ const matches = text2.match(/<system-reminder>[\s\S]*?<\/system-reminder>/g);
1260
+ if (matches) reminders.push(...matches);
1261
+ }
1262
+ return reminders;
1263
+ }
1264
+ function isPlainObject(value) {
1265
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1266
+ }
1267
+
1268
+ // src/provider/tool-mapping.ts
1269
+ var TODO_WRITE_PROGRAM = "__todo_write__";
1270
+ var TODO_READ_PROGRAM = "__todo_read__";
1271
+ var TODO_STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed", "cancelled"]);
1272
+ var TODO_PRIORITIES = /* @__PURE__ */ new Set(["high", "medium", "low"]);
1273
+ function mapDuoToolRequest(toolName, args) {
1274
+ switch (toolName) {
1275
+ case "list_dir": {
1276
+ const directory = asString2(args.directory) ?? ".";
1277
+ return { toolName: "read", args: { filePath: directory } };
1278
+ }
1279
+ case "read_file": {
1280
+ const filePath = asString2(args.file_path) ?? asString2(args.filepath) ?? asString2(args.filePath) ?? asString2(args.path);
1281
+ if (!filePath) return { toolName, args };
1282
+ const mapped = { filePath };
1283
+ if (typeof args.offset === "number") mapped.offset = args.offset;
1284
+ if (typeof args.limit === "number") mapped.limit = args.limit;
1285
+ return { toolName: "read", args: mapped };
1286
+ }
1287
+ case "read_files": {
1288
+ const filePaths = asStringArray(args.file_paths);
1289
+ if (filePaths.length === 0) return { toolName, args };
1290
+ return filePaths.map((fp) => ({ toolName: "read", args: { filePath: fp } }));
1291
+ }
1292
+ case "create_file_with_contents": {
1293
+ const filePath = asString2(args.file_path);
1294
+ const content = asString2(args.contents);
1295
+ if (!filePath || content === void 0) return { toolName, args };
1296
+ return { toolName: "write", args: { filePath, content } };
1297
+ }
1298
+ case "edit_file": {
1299
+ const filePath = asString2(args.file_path);
1300
+ const oldString = asString2(args.old_str);
1301
+ const newString = asString2(args.new_str);
1302
+ if (!filePath || oldString === void 0 || newString === void 0) return { toolName, args };
1303
+ return { toolName: "edit", args: { filePath, oldString, newString } };
1304
+ }
1305
+ case "find_files": {
1306
+ const pattern = asString2(args.name_pattern);
1307
+ if (!pattern) return { toolName, args };
1308
+ return { toolName: "glob", args: { pattern } };
1309
+ }
1310
+ case "grep": {
1311
+ const pattern = asString2(args.pattern);
1312
+ if (!pattern) return { toolName, args };
1313
+ const searchDir = asString2(args.search_directory);
1314
+ const caseInsensitive = Boolean(args.case_insensitive);
1315
+ const normalizedPattern = caseInsensitive && !pattern.startsWith("(?i)") ? `(?i)${pattern}` : pattern;
1316
+ const mapped = { pattern: normalizedPattern };
1317
+ if (searchDir) mapped.path = searchDir;
1318
+ return { toolName: "grep", args: mapped };
1319
+ }
1320
+ case "mkdir": {
1321
+ const directory = asString2(args.directory_path);
1322
+ if (!directory) return { toolName, args };
1323
+ return { toolName: "bash", args: { command: `mkdir -p ${shellQuote(directory)}`, description: "Create directory", workdir: "." } };
1324
+ }
1325
+ case "shell_command": {
1326
+ const command = asString2(args.command);
1327
+ if (!command) return { toolName, args };
1328
+ return { toolName: "bash", args: { command, description: "Run shell command", workdir: "." } };
1329
+ }
1330
+ case "run_command": {
1331
+ const program = asString2(args.program);
1332
+ if (program === TODO_READ_PROGRAM) {
1333
+ return { toolName: "todoread", args: {} };
1334
+ }
1335
+ if (program === TODO_WRITE_PROGRAM) {
1336
+ return mapTodoWriteCall(args.arguments);
1337
+ }
1338
+ if (program) {
1339
+ const parts = [shellQuote(program)];
1340
+ if (Array.isArray(args.flags)) parts.push(...args.flags.map((f) => shellQuote(String(f))));
1341
+ if (Array.isArray(args.arguments)) parts.push(...args.arguments.map((a) => shellQuote(String(a))));
1342
+ return { toolName: "bash", args: { command: parts.join(" "), description: "Run command", workdir: "." } };
1343
+ }
1344
+ const command = asString2(args.command);
1345
+ if (!command) return { toolName, args };
1346
+ return { toolName: "bash", args: { command, description: "Run command", workdir: "." } };
1347
+ }
1348
+ case "run_git_command": {
1349
+ const command = asString2(args.command);
1350
+ if (!command) return { toolName, args };
1351
+ const rawArgs = args.args;
1352
+ const extraArgs = Array.isArray(rawArgs) ? rawArgs.map((v) => shellQuote(String(v))).join(" ") : asString2(rawArgs);
1353
+ const gitCmd = extraArgs ? `git ${shellQuote(command)} ${extraArgs}` : `git ${shellQuote(command)}`;
1354
+ return { toolName: "bash", args: { command: gitCmd, description: "Run git command", workdir: "." } };
1355
+ }
1356
+ case "gitlab_api_request": {
1357
+ const method = asString2(args.method) ?? "GET";
1358
+ const apiPath = asString2(args.path);
1359
+ if (!apiPath) return { toolName, args };
1360
+ const body = asString2(args.body);
1361
+ const curlParts = [
1362
+ "curl",
1363
+ "-s",
1364
+ "-X",
1365
+ method,
1366
+ "-H",
1367
+ "'Authorization: Bearer $GITLAB_TOKEN'",
1368
+ "-H",
1369
+ "'Content-Type: application/json'"
1370
+ ];
1371
+ if (body) {
1372
+ curlParts.push("-d", shellQuote(body));
1373
+ }
1374
+ curlParts.push(shellQuote(`$GITLAB_INSTANCE_URL/api/v4/${apiPath}`));
1375
+ return {
1376
+ toolName: "bash",
1377
+ args: { command: curlParts.join(" "), description: `GitLab API: ${method} ${apiPath}`, workdir: "." }
1378
+ };
1379
+ }
1380
+ default:
1381
+ return { toolName, args };
1382
+ }
1383
+ }
1384
+ function asString2(value) {
1385
+ return typeof value === "string" ? value : void 0;
1386
+ }
1387
+ function mapTodoWriteCall(rawArguments) {
1388
+ const payloadResult = parseTodoPayload(rawArguments);
1389
+ if ("error" in payloadResult) return invalidTool("todowrite", payloadResult.error);
1390
+ const todosResult = parseTodos(payloadResult.payload);
1391
+ if ("error" in todosResult) return invalidTool("todowrite", todosResult.error);
1392
+ return {
1393
+ toolName: "todowrite",
1394
+ args: {
1395
+ todos: todosResult.todos
1396
+ }
1397
+ };
1398
+ }
1399
+ function parseTodoPayload(rawArguments) {
1400
+ if (!Array.isArray(rawArguments) || rawArguments.length === 0) {
1401
+ return {
1402
+ error: `${TODO_WRITE_PROGRAM} expects JSON payload in arguments[0]`
1403
+ };
1404
+ }
1405
+ const rawPayload = rawArguments[0];
1406
+ if (typeof rawPayload !== "string") {
1407
+ return {
1408
+ error: `${TODO_WRITE_PROGRAM} expects arguments[0] to be a JSON string`
1409
+ };
1410
+ }
1411
+ try {
1412
+ const parsed = JSON.parse(rawPayload);
1413
+ if (!isRecord(parsed)) {
1414
+ return {
1415
+ error: `${TODO_WRITE_PROGRAM} payload must be a JSON object`
1416
+ };
1417
+ }
1418
+ return { payload: parsed };
1419
+ } catch {
1420
+ return {
1421
+ error: `${TODO_WRITE_PROGRAM} payload is not valid JSON`
1422
+ };
1423
+ }
1424
+ }
1425
+ function parseTodos(payload) {
1426
+ const rawTodos = payload.todos;
1427
+ if (!Array.isArray(rawTodos)) {
1428
+ return {
1429
+ error: `${TODO_WRITE_PROGRAM} payload must include a todos array`
1430
+ };
1431
+ }
1432
+ const todos = [];
1433
+ for (let index = 0; index < rawTodos.length; index += 1) {
1434
+ const rawTodo = rawTodos[index];
1435
+ if (!isRecord(rawTodo)) {
1436
+ return {
1437
+ error: `${TODO_WRITE_PROGRAM} todos[${index}] must be an object`
1438
+ };
1439
+ }
1440
+ const content = asString2(rawTodo.content);
1441
+ if (content === void 0) {
1442
+ return {
1443
+ error: `${TODO_WRITE_PROGRAM} todos[${index}].content must be a string`
1444
+ };
1445
+ }
1446
+ const status = asString2(rawTodo.status);
1447
+ if (!status || !TODO_STATUSES.has(status)) {
1448
+ return {
1449
+ error: `${TODO_WRITE_PROGRAM} todos[${index}].status must be one of: pending, in_progress, completed, cancelled`
1450
+ };
1451
+ }
1452
+ const priority = asString2(rawTodo.priority);
1453
+ if (!priority || !TODO_PRIORITIES.has(priority)) {
1454
+ return {
1455
+ error: `${TODO_WRITE_PROGRAM} todos[${index}].priority must be one of: high, medium, low`
1456
+ };
1457
+ }
1458
+ todos.push({
1459
+ content,
1460
+ status,
1461
+ priority
1462
+ });
1463
+ }
1464
+ return { todos };
1465
+ }
1466
+ function invalidTool(tool2, error) {
1467
+ return {
1468
+ toolName: "invalid",
1469
+ args: {
1470
+ tool: tool2,
1471
+ error
1472
+ }
1473
+ };
1474
+ }
1475
+ function isRecord(value) {
1476
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1477
+ }
1478
+ function asStringArray(value) {
1479
+ if (!Array.isArray(value)) return [];
1480
+ return value.filter((v) => typeof v === "string");
1481
+ }
1482
+ function shellQuote(s) {
1483
+ if (/^[a-zA-Z0-9_\-./=:@]+$/.test(s)) return s;
1484
+ return `'${s.replace(/'/g, "'\\''")}'`;
1485
+ }
1486
+
1487
+ // src/provider/session-context.ts
1488
+ function readSessionID(options) {
1489
+ const providerBlock = readProviderBlock(options);
1490
+ if (typeof providerBlock?.workflowSessionID === "string" && providerBlock.workflowSessionID.trim()) {
1491
+ return providerBlock.workflowSessionID.trim();
1492
+ }
1493
+ const headers = options.headers ?? {};
1494
+ for (const [key, value] of Object.entries(headers)) {
1495
+ if (key.toLowerCase() === "x-opencode-session" && value?.trim()) return value.trim();
1496
+ }
1497
+ return void 0;
1498
+ }
1499
+ function readProviderBlock(options) {
1500
+ const block = options.providerOptions?.[PROVIDER_ID];
1501
+ if (block && typeof block === "object" && !Array.isArray(block)) {
1502
+ return block;
1503
+ }
1504
+ return void 0;
1505
+ }
1506
+
1507
+ // src/provider/system-context.ts
1508
+ import os2 from "os";
1509
+ function buildSystemContext() {
1510
+ const platform = os2.platform();
1511
+ const arch = os2.arch();
1512
+ return [
1513
+ {
1514
+ category: "os_information",
1515
+ content: `<os><platform>${platform}</platform><architecture>${arch}</architecture></os>`,
1516
+ id: "os_information",
1517
+ metadata: JSON.stringify({
1518
+ title: "Operating System",
1519
+ enabled: true,
1520
+ subType: "os"
1521
+ })
1522
+ },
1523
+ {
1524
+ category: "user_rule",
1525
+ content: SYSTEM_RULES,
1526
+ id: "user_rules",
1527
+ metadata: JSON.stringify({
1528
+ title: "System Rules",
1529
+ enabled: true,
1530
+ subType: "user_rule"
1531
+ })
1532
+ }
1533
+ ];
1534
+ }
1535
+ var SYSTEM_RULES = `<system-reminder>
1536
+ You MUST follow ALL the rules in this block strictly.
1537
+
1538
+ <tool_orchestration>
1539
+ PARALLEL EXECUTION:
1540
+ - When gathering information, plan all needed searches upfront and execute
1541
+ them together using multiple tool calls in the same turn where possible.
1542
+ - Read multiple related files together rather than one at a time.
1543
+ - Patterns: grep + find_files together, read_file for multiple files together.
1544
+
1545
+ SEQUENTIAL EXECUTION (only when output depends on previous step):
1546
+ - Read a file BEFORE editing it (always).
1547
+ - Check dependencies BEFORE importing them.
1548
+ - Run tests AFTER making changes.
1549
+
1550
+ READ BEFORE WRITE:
1551
+ - Always read existing files before modifying them to understand context.
1552
+ - Check for existing patterns (naming, imports, error handling) and match them.
1553
+ - Verify the exact content to replace when using edit_file.
1554
+
1555
+ ERROR HANDLING:
1556
+ - If a tool fails, analyze the error before retrying.
1557
+ - If a shell command fails, check the error output and adapt.
1558
+ - Do not repeat the same failing operation without changes.
1559
+ </tool_orchestration>
1560
+
1561
+ <development_workflow>
1562
+ For software development tasks, follow this workflow:
1563
+
1564
+ 1. UNDERSTAND: Read relevant files, explore the codebase structure
1565
+ 2. PLAN: Break down the task into clear steps
1566
+ 3. IMPLEMENT: Make changes methodically, one step at a time
1567
+ 4. VERIFY: Run tests, type-checking, or build to validate changes
1568
+ 5. COMPLETE: Summarize what was accomplished
1569
+
1570
+ CODE QUALITY:
1571
+ - Match existing code style and patterns in the project
1572
+ - Write immediately executable code (no TODOs or placeholders)
1573
+ - Prefer editing existing files over creating new ones
1574
+ - Use the project's established error handling patterns
1575
+ </development_workflow>
1576
+
1577
+ <communication>
1578
+ - Be concise and direct. Responses appear in a chat panel.
1579
+ - Focus on practical solutions over theoretical discussion.
1580
+ - When unable to complete a request, explain the limitation briefly and
1581
+ provide alternatives.
1582
+ - Use active language: "Analyzing...", "Searching..." instead of "Let me..."
1583
+ </communication>
1584
+ </system-reminder>`;
1585
+
1586
+ // src/workflow/session-store.ts
1587
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
1588
+ import { join } from "path";
1589
+ import { homedir } from "os";
1590
+ function getStorePath() {
1591
+ const dir = process.env.XDG_CACHE_HOME?.trim() ? join(process.env.XDG_CACHE_HOME, "opencode") : join(homedir(), ".cache", "opencode");
1592
+ return join(dir, "duo-workflow-sessions.json");
1593
+ }
1594
+ function readStore() {
1595
+ try {
1596
+ return JSON.parse(readFileSync(getStorePath(), "utf8"));
1597
+ } catch {
1598
+ return {};
1599
+ }
1600
+ }
1601
+ function writeStore(store) {
1602
+ try {
1603
+ const storePath = getStorePath();
1604
+ mkdirSync(join(storePath, ".."), { recursive: true });
1605
+ writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8");
1606
+ } catch {
1607
+ }
1608
+ }
1609
+ function saveWorkflowId(key, workflowId) {
1610
+ const store = readStore();
1611
+ store[key] = workflowId;
1612
+ writeStore(store);
1613
+ }
1614
+ function loadWorkflowId(key) {
1615
+ const store = readStore();
1616
+ return store[key];
1617
+ }
1618
+
1619
+ // src/workflow/flow-config.ts
1620
+ var TOOL_ALLOWLIST = [
1621
+ "read_file",
1622
+ "read_files",
1623
+ "create_file_with_contents",
1624
+ "edit_file",
1625
+ "list_dir",
1626
+ "find_files",
1627
+ "grep",
1628
+ "mkdir",
1629
+ "run_command"
1630
+ ];
1631
+ function buildFlowConfig(systemPrompt) {
1632
+ return {
1633
+ version: "v1",
1634
+ environment: "chat-partial",
1635
+ components: [
1636
+ {
1637
+ // Keep "chat" for behavior parity with GitLab Duo CLI flow metadata.
1638
+ name: "chat",
1639
+ type: "AgentComponent",
1640
+ prompt_id: "chat/agent",
1641
+ toolset: [...TOOL_ALLOWLIST]
1642
+ }
1643
+ ],
1644
+ prompts: [
1645
+ {
1646
+ name: "chat",
1647
+ prompt_id: "chat/agent",
1648
+ unit_primitives: ["duo_chat"],
1649
+ prompt_template: {
1650
+ system: systemPrompt
1651
+ }
1652
+ }
1653
+ ]
1654
+ };
1655
+ }
1656
+
1657
+ // src/provider/duo-workflow-model.ts
1658
+ var sessions = /* @__PURE__ */ new Map();
1659
+ var UNKNOWN_USAGE = {
1660
+ inputTokens: void 0,
1661
+ outputTokens: void 0,
1662
+ totalTokens: void 0
1663
+ };
1664
+ var DuoWorkflowModel = class {
1665
+ specificationVersion = "v2";
1666
+ provider = PROVIDER_ID;
1667
+ modelId;
1668
+ supportedUrls = {};
1669
+ #client;
1670
+ #cwd;
1671
+ #toolsConfig;
1672
+ // Tool tracking state (per model instance, reset on session change)
1673
+ #pendingToolRequests = /* @__PURE__ */ new Map();
1674
+ #multiCallGroups = /* @__PURE__ */ new Map();
1675
+ #sentToolCallIds = /* @__PURE__ */ new Set();
1676
+ #lastSentGoal = null;
1677
+ #stateSessionId;
1678
+ constructor(modelId, client, cwd) {
1679
+ this.modelId = modelId;
1680
+ this.#client = client;
1681
+ this.#cwd = cwd ?? process.cwd();
1682
+ }
1683
+ /**
1684
+ * Opt-in: override the server-side system prompt and/or register MCP tools.
1685
+ */
1686
+ setToolsConfig(config) {
1687
+ this.#toolsConfig = config;
1688
+ for (const session of sessions.values()) {
1689
+ session.setToolsConfig(config);
1690
+ }
1691
+ }
1692
+ async doGenerate(options) {
1693
+ let text2 = "";
1694
+ const { stream } = await this.doStream(options);
1695
+ for await (const part of stream) {
1696
+ if (part.type === "text-delta") text2 += part.delta;
1697
+ }
1698
+ return {
1699
+ content: [{ type: "text", text: text2 }],
1700
+ finishReason: "stop",
1701
+ usage: UNKNOWN_USAGE,
1702
+ warnings: []
1703
+ };
1704
+ }
1705
+ async doStream(options) {
1706
+ const sessionID = readSessionID(options);
1707
+ if (!sessionID) throw new Error("missing workflow session ID");
1708
+ const goal = extractGoal(options.prompt);
1709
+ const toolResults = extractToolResults(options.prompt);
1710
+ const session = this.#resolveSession(sessionID);
1711
+ const textId = randomUUID3();
1712
+ dlog(`doStream: goal=${goal?.length ?? 0}ch toolResults=${toolResults.length} hasStarted=${session.hasStarted} pending=${this.#pendingToolRequests.size} sent=${this.#sentToolCallIds.size}`);
1713
+ if (sessionID !== this.#stateSessionId) {
1714
+ this.#pendingToolRequests.clear();
1715
+ this.#multiCallGroups.clear();
1716
+ this.#sentToolCallIds.clear();
1717
+ this.#lastSentGoal = null;
1718
+ this.#stateSessionId = sessionID;
1719
+ }
1720
+ const model = this;
1721
+ return {
1722
+ stream: new ReadableStream({
1723
+ start: async (controller) => {
1724
+ controller.enqueue({ type: "stream-start", warnings: [] });
1725
+ const onAbort = () => session.abort();
1726
+ options.abortSignal?.addEventListener("abort", onAbort, { once: true });
1727
+ try {
1728
+ if (!session.hasStarted) {
1729
+ model.#sentToolCallIds.clear();
1730
+ for (const r of toolResults) {
1731
+ if (!model.#pendingToolRequests.has(r.toolCallId)) {
1732
+ model.#sentToolCallIds.add(r.toolCallId);
1733
+ }
1734
+ }
1735
+ model.#lastSentGoal = null;
1736
+ }
1737
+ await session.ensureConnected(goal || "");
1738
+ const freshResults = toolResults.filter(
1739
+ (r) => !model.#sentToolCallIds.has(r.toolCallId)
1740
+ );
1741
+ dlog(`phase1: ${toolResults.length} total, ${freshResults.length} fresh`);
1742
+ let sentToolResults = false;
1743
+ for (const result of freshResults) {
1744
+ const subIdx = result.toolCallId.indexOf("_sub_");
1745
+ if (subIdx !== -1) {
1746
+ const originalId = result.toolCallId.substring(0, subIdx);
1747
+ const group = model.#multiCallGroups.get(originalId);
1748
+ if (!group) {
1749
+ model.#sentToolCallIds.add(result.toolCallId);
1750
+ continue;
1751
+ }
1752
+ group.collected.set(result.toolCallId, result.error ?? result.output);
1753
+ model.#sentToolCallIds.add(result.toolCallId);
1754
+ model.#pendingToolRequests.delete(result.toolCallId);
1755
+ if (group.collected.size === group.subIds.length) {
1756
+ const result2 = {};
1757
+ for (let i = 0; i < group.subIds.length; i++) {
1758
+ const label = group.labels[i] || `file_${i}`;
1759
+ const value = group.collected.get(group.subIds[i]) ?? "";
1760
+ result2[label] = { content: value };
1761
+ }
1762
+ session.sendToolResult(originalId, JSON.stringify(result2));
1763
+ model.#multiCallGroups.delete(originalId);
1764
+ model.#pendingToolRequests.delete(originalId);
1765
+ sentToolResults = true;
1766
+ }
1767
+ continue;
1768
+ }
1769
+ const pending = model.#pendingToolRequests.get(result.toolCallId);
1770
+ if (!pending) {
1771
+ dlog(`phase1: SKIP ${result.toolCallId} (not pending)`);
1772
+ model.#sentToolCallIds.add(result.toolCallId);
1773
+ continue;
1774
+ }
1775
+ dlog(`phase1: SEND ${result.toolCallId} output=${result.output.length}b`);
1776
+ session.sendToolResult(result.toolCallId, result.output, result.error);
1777
+ sentToolResults = true;
1778
+ model.#sentToolCallIds.add(result.toolCallId);
1779
+ model.#pendingToolRequests.delete(result.toolCallId);
1780
+ }
1781
+ const isNewGoal = goal && goal !== model.#lastSentGoal;
1782
+ if (!sentToolResults && isNewGoal) {
1783
+ await session.ensureConnected(goal);
1784
+ if (!session.hasStarted) {
1785
+ const extraContext = [];
1786
+ const extractedSystemPrompt = extractSystemPrompt(options.prompt);
1787
+ const sanitizedSystemPrompt = sanitizeSystemPrompt(
1788
+ extractedSystemPrompt ?? "You are GitLab Duo, an AI coding assistant."
1789
+ );
1790
+ session.setToolsConfig({
1791
+ mcpTools: [],
1792
+ flowConfig: buildFlowConfig(sanitizedSystemPrompt),
1793
+ flowConfigSchemaVersion: "v1"
1794
+ });
1795
+ extraContext.push(...buildSystemContext());
1796
+ const agentReminders = extractAgentReminders(options.prompt);
1797
+ if (agentReminders.length > 0) {
1798
+ extraContext.push({
1799
+ category: "agent_context",
1800
+ content: sanitizeSystemPrompt(
1801
+ `[context-id:${Date.now()}]
1802
+ ${agentReminders.join("\n\n")}`
1803
+ ),
1804
+ id: "agent_reminders",
1805
+ metadata: JSON.stringify({
1806
+ title: "Agent Reminders",
1807
+ enabled: true,
1808
+ subType: "agent_reminders"
1809
+ })
1810
+ });
1811
+ }
1812
+ session.sendStartRequest(goal, extraContext);
1813
+ }
1814
+ model.#lastSentGoal = goal;
1815
+ }
1816
+ let hasText = false;
1817
+ while (true) {
1818
+ const event = await session.waitForEvent();
1819
+ if (!event) break;
1820
+ if (event.type === "text-delta") {
1821
+ if (!event.value) continue;
1822
+ if (!hasText) {
1823
+ hasText = true;
1824
+ controller.enqueue({ type: "text-start", id: textId });
1825
+ }
1826
+ controller.enqueue({ type: "text-delta", id: textId, delta: event.value });
1827
+ continue;
1828
+ }
1829
+ if (event.type === "tool-request") {
1830
+ let mapped;
1831
+ try {
1832
+ mapped = mapDuoToolRequest(event.toolName, event.args);
1833
+ } catch {
1834
+ dlog(`phase3: MAPPING FAILED ${event.toolName}`);
1835
+ continue;
1836
+ }
1837
+ const mName = Array.isArray(mapped) ? mapped.map((m) => m.toolName).join(",") : mapped.toolName;
1838
+ dlog(`phase3: tool-request ${event.toolName} \u2192 ${mName} reqId=${event.requestId}`);
1839
+ if (hasText) {
1840
+ controller.enqueue({ type: "text-end", id: textId });
1841
+ }
1842
+ if (Array.isArray(mapped)) {
1843
+ const subIds = mapped.map((_, i) => `${event.requestId}_sub_${i}`);
1844
+ model.#multiCallGroups.set(event.requestId, {
1845
+ subIds,
1846
+ labels: mapped.map((m) => String(m.args.filePath ?? m.args.path ?? "")),
1847
+ collected: /* @__PURE__ */ new Map()
1848
+ });
1849
+ model.#pendingToolRequests.set(event.requestId, {});
1850
+ for (const subId of subIds) {
1851
+ model.#pendingToolRequests.set(subId, {});
1852
+ }
1853
+ for (let i = 0; i < mapped.length; i++) {
1854
+ const inputJson = JSON.stringify(mapped[i].args);
1855
+ controller.enqueue({ type: "tool-input-start", id: subIds[i], toolName: mapped[i].toolName });
1856
+ controller.enqueue({ type: "tool-input-delta", id: subIds[i], delta: inputJson });
1857
+ controller.enqueue({ type: "tool-input-end", id: subIds[i] });
1858
+ controller.enqueue({
1859
+ type: "tool-call",
1860
+ toolCallId: subIds[i],
1861
+ toolName: mapped[i].toolName,
1862
+ input: inputJson
1863
+ });
1864
+ }
1865
+ } else {
1866
+ model.#pendingToolRequests.set(event.requestId, {});
1867
+ const inputJson = JSON.stringify(mapped.args);
1868
+ controller.enqueue({ type: "tool-input-start", id: event.requestId, toolName: mapped.toolName });
1869
+ controller.enqueue({ type: "tool-input-delta", id: event.requestId, delta: inputJson });
1870
+ controller.enqueue({ type: "tool-input-end", id: event.requestId });
1871
+ controller.enqueue({
1872
+ type: "tool-call",
1873
+ toolCallId: event.requestId,
1874
+ toolName: mapped.toolName,
1875
+ input: inputJson
1876
+ });
1877
+ }
1878
+ controller.enqueue({
1879
+ type: "finish",
1880
+ finishReason: "tool-calls",
1881
+ usage: UNKNOWN_USAGE
1882
+ });
1883
+ controller.close();
1884
+ return;
1885
+ }
1886
+ if (event.type === "error") {
1887
+ controller.enqueue({ type: "error", error: new Error(event.message) });
1888
+ controller.enqueue({ type: "finish", finishReason: "error", usage: UNKNOWN_USAGE });
1889
+ controller.close();
1890
+ return;
1891
+ }
1892
+ }
1893
+ if (hasText) {
1894
+ controller.enqueue({ type: "text-end", id: textId });
1895
+ }
1896
+ controller.enqueue({ type: "finish", finishReason: "stop", usage: UNKNOWN_USAGE });
1897
+ controller.close();
1898
+ } catch (error) {
1899
+ controller.enqueue({ type: "error", error });
1900
+ controller.enqueue({ type: "finish", finishReason: "error", usage: UNKNOWN_USAGE });
1901
+ controller.close();
1902
+ } finally {
1903
+ options.abortSignal?.removeEventListener("abort", onAbort);
1904
+ }
1905
+ }
1906
+ }),
1907
+ request: {
1908
+ body: {
1909
+ goal,
1910
+ workflowID: session.workflowId
1911
+ }
1912
+ }
1913
+ };
1914
+ }
1915
+ /** Remove a cached session, freeing its resources. */
1916
+ disposeSession(sessionID) {
1917
+ return sessions.delete(sessionKey(this.#client.instanceUrl, this.modelId, sessionID));
1918
+ }
1919
+ #resolveSession(sessionID) {
1920
+ const key = sessionKey(this.#client.instanceUrl, this.modelId, sessionID);
1921
+ const existing = sessions.get(key);
1922
+ if (existing) return existing;
1923
+ const existingWorkflowId = loadWorkflowId(key);
1924
+ if (existingWorkflowId) {
1925
+ dlog(`resolveSession: restored workflowId=${existingWorkflowId} from disk for ${sessionID}`);
1926
+ }
1927
+ const created = new WorkflowSession(this.#client, this.modelId, this.#cwd, {
1928
+ existingWorkflowId,
1929
+ onWorkflowCreated: (workflowId) => {
1930
+ dlog(`resolveSession: saving workflowId=${workflowId} for ${sessionID}`);
1931
+ saveWorkflowId(key, workflowId);
1932
+ }
1933
+ });
1934
+ if (this.#toolsConfig) created.setToolsConfig(this.#toolsConfig);
1935
+ sessions.set(key, created);
1936
+ return created;
1937
+ }
1938
+ };
1939
+ function sessionKey(instanceUrl, modelId, sessionID) {
1940
+ return `${instanceUrl}::${modelId}::${sessionID}`;
1941
+ }
1942
+
1943
+ // src/provider/index.ts
1944
+ function createFallbackProvider(input = {}) {
1945
+ const client = resolveCredentials(input);
1946
+ return {
1947
+ languageModel(modelId) {
1948
+ return new DuoWorkflowModel(modelId, client);
1949
+ },
1950
+ agenticChat(modelId, _options) {
1951
+ return new DuoWorkflowModel(modelId, client);
1952
+ },
1953
+ textEmbeddingModel(modelId) {
1954
+ throw new NoSuchModelError({ modelId, modelType: "textEmbeddingModel" });
1955
+ },
1956
+ imageModel(modelId) {
1957
+ throw new NoSuchModelError({ modelId, modelType: "imageModel" });
1958
+ }
1959
+ };
1960
+ }
1961
+
1962
+ // src/index.ts
1963
+ function isPluginInput(value) {
1964
+ if (!value || typeof value !== "object") return false;
1965
+ const input = value;
1966
+ return "client" in input && "project" in input && "directory" in input && "worktree" in input && "serverUrl" in input;
1967
+ }
1968
+ var entry = (input) => {
1969
+ if (isPluginInput(input)) {
1970
+ return createPluginHooks(input);
1971
+ }
1972
+ return createFallbackProvider(input);
1973
+ };
1974
+ var createGitLabDuoAgentic = entry;
1975
+ var GitLabDuoAgenticPlugin = entry;
1976
+ var src_default = GitLabDuoAgenticPlugin;
1977
+ export {
1978
+ GitLabDuoAgenticPlugin,
1979
+ createGitLabDuoAgentic,
1980
+ src_default as default
1981
+ };