clay-server 3.3.2-beta.3 → 3.4.0-beta.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 (36) hide show
  1. package/lib/project-file-watch.js +77 -16
  2. package/lib/project-shell-command.js +160 -0
  3. package/lib/project-user-message.js +10 -0
  4. package/lib/project.js +13 -0
  5. package/lib/public/app.js +10 -0
  6. package/lib/public/css/input.css +67 -0
  7. package/lib/public/css/mates.css +1 -0
  8. package/lib/public/gemini-avatar.svg +11 -0
  9. package/lib/public/index.html +13 -0
  10. package/lib/public/modules/app-messages.js +16 -1
  11. package/lib/public/modules/app-panels.js +13 -1
  12. package/lib/public/modules/app-projects.js +2 -0
  13. package/lib/public/modules/app-rendering.js +7 -1
  14. package/lib/public/modules/input.js +58 -28
  15. package/lib/public/modules/mate-sidebar.js +9 -3
  16. package/lib/public/modules/shell-command.js +148 -0
  17. package/lib/public/modules/sidebar-mates.js +7 -1
  18. package/lib/public/modules/tools.js +7 -1
  19. package/lib/public/opencode-avatar.svg +4 -0
  20. package/lib/sdk-bridge.js +18 -2
  21. package/lib/sdk-message-processor.js +10 -1
  22. package/lib/ws-schema.js +3 -0
  23. package/lib/yoke/acp-agent-profiles.js +188 -0
  24. package/lib/yoke/acp-driver-runtime.js +50 -0
  25. package/lib/yoke/acp-event-normalizer.js +179 -0
  26. package/lib/yoke/acp-process-manager.js +264 -0
  27. package/lib/yoke/acp-query-handle.js +487 -0
  28. package/lib/yoke/adapters/acp.js +317 -0
  29. package/lib/yoke/adapters/gemini.js +7 -0
  30. package/lib/yoke/adapters/kiro.js +4 -4
  31. package/lib/yoke/adapters/opencode.js +7 -0
  32. package/lib/yoke/index.js +45 -11
  33. package/lib/yoke/interface.js +2 -0
  34. package/lib/yoke/kiro-acp-server.js +30 -276
  35. package/lib/yoke/vendor-registry.js +22 -0
  36. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { mateAvatarUrl } from './avatar.js';
7
7
  import { tuiIsActive, tuiSubmitText } from './session-tui-view.js';
8
8
  import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
9
9
  import { showToast } from './utils.js';
10
+ import { isShellCommandMode, submitShellCommand } from './shell-command.js';
10
11
 
11
12
  var ctx;
12
13
 
@@ -75,6 +76,37 @@ export var builtinCommands = [
75
76
  { name: "status", desc: "Process status and resource usage" },
76
77
  ];
77
78
 
79
+ function commitVendorForTurn() {
80
+ var committedVendor = store.get('currentVendor');
81
+ var vendorToggle = document.getElementById("vendor-toggle-wrap");
82
+ var activeIndicator = document.getElementById("active-vendor-indicator");
83
+ var activeIcon = document.getElementById("active-vendor-icon");
84
+ if (committedVendor) {
85
+ if (vendorToggle) {
86
+ vendorToggle.classList.add("hidden");
87
+ vendorToggle.classList.remove("locked");
88
+ }
89
+ if (activeIndicator && activeIcon) {
90
+ activeIcon.src = VENDOR_AVATARS[committedVendor] || VENDOR_AVATARS.claude;
91
+ activeIcon.alt = VENDOR_NAMES[committedVendor] || VENDOR_NAMES.claude;
92
+ activeIndicator.title = (VENDOR_NAMES[committedVendor] || VENDOR_NAMES.claude) + " session";
93
+ activeIndicator.classList.remove("hidden");
94
+ }
95
+ } else if (vendorToggle) {
96
+ vendorToggle.classList.remove("hidden");
97
+ vendorToggle.classList.add("locked");
98
+ }
99
+ store.set({ vendorSelectionLocked: false, sessionHasHistory: true });
100
+ }
101
+
102
+ function showPreThinkingForTurn() {
103
+ if (ctx.isMateDm && ctx.isMateDm()) {
104
+ ctx.showMatePreThinking();
105
+ } else if (ctx.showClaudePreThinking) {
106
+ ctx.showClaudePreThinking();
107
+ }
108
+ }
109
+
78
110
  // --- Send ---
79
111
  export function sendMessage() {
80
112
  // Debate ended mode intercept: route to resume handler
@@ -92,6 +124,14 @@ export function sendMessage() {
92
124
  ctx.handleDebateFloorSend();
93
125
  return;
94
126
  }
127
+ if (isShellCommandMode()) {
128
+ var shellText = ctx.inputEl.value.trim();
129
+ if (!shellText || !submitShellCommand(shellText)) return;
130
+ ctx.inputEl.value = "";
131
+ sendInputSync();
132
+ autoResize();
133
+ return;
134
+ }
95
135
  // DM mode intercept: if in DM mode, route to DM handler instead
96
136
  if (ctx.isDmMode && ctx.isDmMode() && ctx.handleDmSend) {
97
137
  ctx.handleDmSend();
@@ -304,36 +344,10 @@ export function sendMessage() {
304
344
  // Bumping sessionHasHistory drives in-session lock states (e.g. the
305
345
  // Codex model picker that becomes informational once the thread is
306
346
  // bound to a model).
307
- var _committedVendor = store.get('currentVendor');
308
- var _vtw2 = document.getElementById("vendor-toggle-wrap");
309
- var _avi = document.getElementById("active-vendor-indicator");
310
- var _avIcon = document.getElementById("active-vendor-icon");
311
- if (_committedVendor) {
312
- if (_vtw2) {
313
- _vtw2.classList.add("hidden");
314
- _vtw2.classList.remove("locked");
315
- }
316
- if (_avi && _avIcon) {
317
- _avIcon.src = VENDOR_AVATARS[_committedVendor] || VENDOR_AVATARS.claude;
318
- _avIcon.alt = VENDOR_NAMES[_committedVendor] || VENDOR_NAMES.claude;
319
- _avi.title = (VENDOR_NAMES[_committedVendor] || VENDOR_NAMES.claude) + " session";
320
- _avi.classList.remove("hidden");
321
- }
322
- } else if (_vtw2) {
323
- // No committed vendor (defensive — shouldn't happen because the
324
- // input is otherwise gated on a vendor pick). Fall back to the
325
- // locked toggle so the running vendor is still visible.
326
- _vtw2.classList.remove("hidden");
327
- _vtw2.classList.add("locked");
328
- }
329
- store.set({ vendorSelectionLocked: false, sessionHasHistory: true });
347
+ commitVendorForTurn();
330
348
 
331
349
  // Show pre-thinking dots before server responds
332
- if (ctx.isMateDm && ctx.isMateDm()) {
333
- ctx.showMatePreThinking();
334
- } else if (ctx.showClaudePreThinking) {
335
- ctx.showClaudePreThinking();
336
- }
350
+ showPreThinkingForTurn();
337
351
 
338
352
  ctx.inputEl.value = "";
339
353
  sendInputSync();
@@ -354,6 +368,22 @@ export function sendTextMessage(text) {
354
368
  return true;
355
369
  }
356
370
 
371
+ export function sendShellResultToAgent(msg) {
372
+ if (!ctx || !ctx.connected || !msg || msg.error || !msg.command) return false;
373
+ if (String(msg.sessionId) !== String(store.get("activeSessionId"))) return false;
374
+ var payload = {
375
+ type: "message",
376
+ text: "$ " + msg.command,
377
+ shellCommandResponse: true,
378
+ };
379
+ var selectedVendor = store.get("currentVendor") || null;
380
+ if (selectedVendor) payload.vendor = selectedVendor;
381
+ ctx.ws.send(JSON.stringify(payload));
382
+ commitVendorForTurn();
383
+ showPreThinkingForTurn();
384
+ return true;
385
+ }
386
+
357
387
  export function autoResize() {
358
388
  ctx.inputEl.style.height = "auto";
359
389
  ctx.inputEl.style.height = Math.min(ctx.inputEl.scrollHeight, 120) + "px";
@@ -149,9 +149,15 @@ export function showMateSidebar(mateId, mateData) {
149
149
  if (mateVendorWrap) {
150
150
  var available = store.get('availableVendors') || [];
151
151
  var mateVendor = mateData.vendor || "claude";
152
- var vendorIcons = { claude: "/claude-code-avatar.png", codex: "/codex-avatar.png", kiro: "/kiro-avatar.svg" };
153
- var vendorNames = { claude: "Claude Code", codex: "Codex", kiro: "Kiro CLI" };
154
- var vendorKeys = ["claude", "codex", "kiro"];
152
+ var vendorIcons = {
153
+ claude: "/claude-code-avatar.png",
154
+ codex: "/codex-avatar.png",
155
+ gemini: "/gemini-avatar.svg",
156
+ opencode: "/opencode-avatar.svg",
157
+ kiro: "/kiro-avatar.svg",
158
+ };
159
+ var vendorNames = { claude: "Claude Code", codex: "Codex", gemini: "Gemini CLI", opencode: "OpenCode", kiro: "Kiro CLI" };
160
+ var vendorKeys = ["claude", "codex", "gemini", "opencode", "kiro"];
155
161
  mateVendorWrap.innerHTML = "";
156
162
  for (var vi = 0; vi < vendorKeys.length; vi++) {
157
163
  var vk = vendorKeys[vi];
@@ -0,0 +1,148 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+ import { iconHtml, refreshIcons } from './icons.js';
4
+ import { showToast } from './utils.js';
5
+
6
+ var defaultPlaceholder = "";
7
+
8
+ function getInput() {
9
+ return document.getElementById("input");
10
+ }
11
+
12
+ function appendToMessages(element) {
13
+ var messages = document.getElementById("messages");
14
+ if (!messages) return;
15
+ messages.appendChild(element);
16
+ requestAnimationFrame(function () { messages.scrollTop = messages.scrollHeight; });
17
+ }
18
+
19
+ function setMode(active) {
20
+ var input = getInput();
21
+ var button = document.getElementById("shell-command-btn");
22
+ var row = document.getElementById("input-row");
23
+ store.set({ shellCommandMode: active });
24
+ if (button) {
25
+ button.classList.toggle("active", active);
26
+ button.setAttribute("aria-pressed", active ? "true" : "false");
27
+ }
28
+ if (row) row.classList.toggle("shell-command-mode", active);
29
+ if (input) {
30
+ if (!defaultPlaceholder) defaultPlaceholder = input.placeholder;
31
+ input.placeholder = active ? "Run a shell command in this project…" : defaultPlaceholder;
32
+ input.focus();
33
+ }
34
+ }
35
+
36
+ export function isShellCommandMode() {
37
+ return !!store.get("shellCommandMode");
38
+ }
39
+
40
+ export function toggleShellCommandMode() {
41
+ if (store.get("shellCommandRunning")) return;
42
+ var target = store.get("dmTargetUser");
43
+ if (store.get("dmMode") && target && !target.isMate) {
44
+ showToast("Shell commands are available in agent sessions, not user DMs.", "error");
45
+ return;
46
+ }
47
+ setMode(!isShellCommandMode());
48
+ }
49
+
50
+ function renderPendingCommand(requestId, command) {
51
+ var card = document.createElement("div");
52
+ card.className = "shell-command-card running";
53
+ card.dataset.requestId = requestId;
54
+ card.innerHTML =
55
+ '<div class="shell-command-header">' +
56
+ '<span class="shell-command-icon">' + iconHtml("square-terminal") + '</span>' +
57
+ '<code></code><span class="shell-command-status">Running…</span>' +
58
+ '</div>' +
59
+ '<pre class="shell-command-output">Waiting for output…</pre>';
60
+ card.querySelector("code").textContent = "$ " + command;
61
+ appendToMessages(card);
62
+ refreshIcons();
63
+ }
64
+
65
+ export function submitShellCommand(command) {
66
+ command = String(command || "").trim();
67
+ if (!command || store.get("shellCommandRunning")) return false;
68
+ var ws = getWs();
69
+ if (!ws || ws.readyState !== 1) {
70
+ showToast("Not connected — command not run.", "error");
71
+ return false;
72
+ }
73
+
74
+ var requestId = "shell_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9);
75
+ store.set({ shellCommandRunning: true, pendingShellCommandId: requestId });
76
+ var input = getInput();
77
+ if (input) {
78
+ input.disabled = true;
79
+ input.placeholder = "Running command…";
80
+ }
81
+ renderPendingCommand(requestId, command);
82
+ ws.send(JSON.stringify({ type: "shell_command", requestId: requestId, command: command }));
83
+ return true;
84
+ }
85
+
86
+ export function handleShellCommandResult(msg) {
87
+ var cards = document.querySelectorAll(".shell-command-card[data-request-id]");
88
+ var card = null;
89
+ for (var i = 0; i < cards.length; i++) {
90
+ if (cards[i].dataset.requestId === (msg.requestId || "")) {
91
+ card = cards[i];
92
+ break;
93
+ }
94
+ }
95
+ if (card) {
96
+ var status = card.querySelector(".shell-command-status");
97
+ var output = card.querySelector(".shell-command-output");
98
+ card.classList.remove("running");
99
+ if (msg.error) {
100
+ card.classList.add("error");
101
+ if (status) status.textContent = "Failed";
102
+ if (output) output.textContent = msg.error;
103
+ } else {
104
+ card.classList.toggle("error", msg.exitCode !== 0);
105
+ if (status) status.textContent = msg.timedOut ? "Timed out" : "Exit " + (msg.exitCode == null ? "—" : msg.exitCode);
106
+ if (output) output.textContent = msg.output || "(no output)";
107
+ }
108
+ }
109
+
110
+ store.set({ shellCommandRunning: false, pendingShellCommandId: null });
111
+ var input = getInput();
112
+ if (input) input.disabled = false;
113
+ if (msg.error) {
114
+ setMode(true);
115
+ } else {
116
+ setMode(false);
117
+ }
118
+ var messages = document.getElementById("messages");
119
+ if (messages) requestAnimationFrame(function () { messages.scrollTop = messages.scrollHeight; });
120
+ }
121
+
122
+ export function initShellCommand() {
123
+ var button = document.getElementById("shell-command-btn");
124
+ var mobileButton = document.getElementById("input-more-shell");
125
+ if (button) button.addEventListener("click", toggleShellCommandMode);
126
+ if (mobileButton) {
127
+ mobileButton.addEventListener("click", function () {
128
+ var sheet = document.getElementById("input-more-sheet");
129
+ if (sheet) {
130
+ sheet.classList.remove("open");
131
+ setTimeout(function () { sheet.classList.add("hidden"); }, 250);
132
+ }
133
+ toggleShellCommandMode();
134
+ });
135
+ }
136
+ store.subscribe(function (state, previous) {
137
+ if (previous.connected && !state.connected && state.shellCommandRunning) {
138
+ resetShellCommand();
139
+ }
140
+ });
141
+ }
142
+
143
+ export function resetShellCommand() {
144
+ store.set({ shellCommandMode: false, shellCommandRunning: false, pendingShellCommandId: null });
145
+ var input = getInput();
146
+ if (input) input.disabled = false;
147
+ setMode(false);
148
+ }
@@ -457,7 +457,13 @@ export function renderUserStrip(allUsers, onlineUserIds, myUserId, dmFavorites,
457
457
  // Tooltip
458
458
  var displayName = mp.displayName || mate.name || "New Mate";
459
459
  var mateVendor = mate.vendor || "claude";
460
- var vendorLabels = { claude: "Claude Code", codex: "OpenAI Codex", kiro: "Kiro CLI" };
460
+ var vendorLabels = {
461
+ claude: "Claude Code",
462
+ codex: "OpenAI Codex",
463
+ gemini: "Gemini CLI",
464
+ opencode: "OpenCode",
465
+ kiro: "Kiro CLI",
466
+ };
461
467
  el.addEventListener("mouseenter", function () {
462
468
  var html = '<div style="font-weight:600">' + escapeHtml(displayName);
463
469
  if (mate.primary) {
@@ -814,7 +814,13 @@ function resolvePermissionIdentity(mateId, vendor) {
814
814
  }
815
815
  }
816
816
  // Project chat: use vendor name and avatar
817
- var vendorAvatars = { claude: "/claude-code-avatar.png", codex: "/codex-avatar.png", kiro: "/kiro-avatar.svg" };
817
+ var vendorAvatars = {
818
+ claude: "/claude-code-avatar.png",
819
+ codex: "/codex-avatar.png",
820
+ gemini: "/gemini-avatar.svg",
821
+ opencode: "/opencode-avatar.svg",
822
+ kiro: "/kiro-avatar.svg",
823
+ };
818
824
  var vendorName = (vendor && VENDOR_NAMES[vendor]) || VENDOR_NAMES.claude;
819
825
  return {
820
826
  name: vendorName,
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="OpenCode">
2
+ <rect width="64" height="64" rx="16" fill="#111814"/>
3
+ <path d="m26 19-13 13 13 13M38 19l13 13-13 13" fill="none" stroke="#78e08f" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
4
+ </svg>
package/lib/sdk-bridge.js CHANGED
@@ -129,7 +129,7 @@ function createSDKBridge(opts) {
129
129
  var _cachedFreshAuthState = null;
130
130
  var _cachedFreshAuthAt = 0;
131
131
 
132
- function getKiroMcpServers(session) {
132
+ function getAcpMcpServers(session) {
133
133
  if (!session) return [];
134
134
  var bridgeArgs = [
135
135
  path.join(__dirname, "yoke", "mcp-bridge-server.js"),
@@ -1525,13 +1525,16 @@ function createSDKBridge(opts) {
1525
1525
  sandboxMode: codexConfig.sandbox,
1526
1526
  webSearchMode: codexConfig.webSearch,
1527
1527
  },
1528
+ ACP: {
1529
+ mcpServers: getAcpMcpServers(session),
1530
+ },
1528
1531
  KIRO: {
1529
1532
  engine: kiroConfig.engine,
1530
1533
  mode: kiroConfig.mode,
1531
1534
  // ACP accepts additional stdio MCP servers per session. The bridge is
1532
1535
  // bound to this Clay session so partner calls cannot leak across
1533
1536
  // concurrent Kiro sessions or users.
1534
- mcpServers: getKiroMcpServers(session),
1537
+ mcpServers: getAcpMcpServers(session),
1535
1538
  },
1536
1539
  },
1537
1540
  };
@@ -1787,6 +1790,19 @@ function createSDKBridge(opts) {
1787
1790
  } catch (e) {}
1788
1791
  if ((codexBin && fs.existsSync(codexBin)) || tryLookup(yoke.getVendorInfo("codex").binaryName)) result.push("codex");
1789
1792
 
1793
+ var acpVendorKeys = ["gemini", "opencode"];
1794
+ var acpProfiles = require("./yoke/acp-agent-profiles");
1795
+ for (var acpVendorIndex = 0; acpVendorIndex < acpVendorKeys.length; acpVendorIndex++) {
1796
+ var acpVendor = acpVendorKeys[acpVendorIndex];
1797
+ var acpInfo = yoke.getVendorInfo(acpVendor);
1798
+ if (!linuxUser || acpInfo.osUserIsolation) {
1799
+ var acpProfile = acpProfiles.getAcpAgentProfile(acpVendor);
1800
+ if (acpProfiles.findAcpAgentPath(acpProfile)) result.push(acpVendor);
1801
+ } else {
1802
+ console.log("[sdk-bridge] " + acpInfo.displayName + " hidden for OS-isolated user " + linuxUser + ": per-user spawning is not implemented");
1803
+ }
1804
+ }
1805
+
1790
1806
  // Kiro has no per-user ACP spawn path yet. Do not advertise the daemon's
1791
1807
  // binary to an OS-isolated user, even if that user also has Kiro installed.
1792
1808
  var kiroInfo = yoke.getVendorInfo("kiro");
@@ -792,7 +792,16 @@ function attachMessageProcessor(ctx) {
792
792
  // processQueryStream can finish the turn when the iterator closes.
793
793
  var adapterErrorText = parsed.text || parsed.message || parsed.error || "Agent runtime error";
794
794
  session._lastAdapterError = adapterErrorText;
795
- sendAndRecord(session, { type: "error", text: adapterErrorText });
795
+ var isSessionWriterConflict = /thread-store conflict|already has an active writer/i.test(adapterErrorText);
796
+ if (isSessionWriterConflict) {
797
+ sendAndRecord(session, {
798
+ type: "session_writer_conflict",
799
+ vendor: session.vendor || "codex",
800
+ text: "This Codex session is already open in another Clay or Codex process. Stop the other server or close the other session, then try again.",
801
+ });
802
+ } else {
803
+ sendAndRecord(session, { type: "error", text: adapterErrorText });
804
+ }
796
805
 
797
806
  } else if (parsed.yokeType === "model_refusal") {
798
807
  // Model declined the request. "fallback" => the CLI retried on another
package/lib/ws-schema.js CHANGED
@@ -145,6 +145,7 @@ var schema = {
145
145
  "kill_process": { direction: "c2s", handler: "lib/project-sessions.js", description: "Kill a system process by PID" },
146
146
  "process_killed": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Process was successfully killed" },
147
147
  "process_conflict": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Conflict: another process is using the session" },
148
+ "session_writer_conflict": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Codex session is already open in another process" },
148
149
 
149
150
  // -----------------------------------------------------------------------
150
151
  // Context / usage
@@ -353,6 +354,8 @@ var schema = {
353
354
  "term_closed": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Terminal session was closed" },
354
355
  "term_list": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Full list of open terminals" },
355
356
  "term_error": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Terminal error (e.g. access denied)" },
357
+ "shell_command": { direction: "c2s", handler: "lib/project-shell-command.js", description: "Run a one-shot shell command for agent context" },
358
+ "shell_command_result": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "One-shot shell command output and exit status" },
356
359
 
357
360
  // -----------------------------------------------------------------------
358
361
  // Sticky notes
@@ -0,0 +1,188 @@
1
+ // ACP Agent Drivers
2
+ // -----------------
3
+ // Vendor-specific facts and optional hooks for agents that implement ACP.
4
+ // Static profiles are sufficient for standard agents; richer runtimes can add
5
+ // hooks without forcing YOKE down to ACP's least-common-denominator feature set.
6
+
7
+ var fs = require("fs");
8
+ var execFile = require("child_process").execFile;
9
+ var execFileSync = require("child_process").execFileSync;
10
+
11
+ function findOnPath(binaryName, overrideName) {
12
+ var overridePath = overrideName && process.env[overrideName];
13
+ if (overridePath && fs.existsSync(overridePath)) return overridePath;
14
+ try {
15
+ var command = process.platform === "win32" ? "where" : "which";
16
+ var out = execFileSync(command, [binaryName], {
17
+ timeout: 3000,
18
+ encoding: "utf8",
19
+ stdio: ["pipe", "pipe", "pipe"],
20
+ });
21
+ return out.trim().split(/\r?\n/)[0] || null;
22
+ } catch (e) {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function fetchOpenCodeModels(binaryPath, cwd) {
28
+ return new Promise(function(resolve) {
29
+ execFile(binaryPath, ["models"], {
30
+ cwd: cwd || process.cwd(),
31
+ timeout: 20000,
32
+ maxBuffer: 4 * 1024 * 1024,
33
+ }, function(err, stdout) {
34
+ if (err || !stdout) { resolve([]); return; }
35
+ var seen = {};
36
+ var models = [];
37
+ var lines = String(stdout).split(/\r?\n/);
38
+ for (var i = 0; i < lines.length; i++) {
39
+ var value = lines[i].trim();
40
+ if (!value || seen[value]) continue;
41
+ seen[value] = true;
42
+ models.push(value);
43
+ }
44
+ resolve(models);
45
+ });
46
+ });
47
+ }
48
+
49
+ function fetchOpenCodeResolvedConfig(binaryPath, cwd, env) {
50
+ return new Promise(function(resolve, reject) {
51
+ execFile(binaryPath, ["debug", "config"], {
52
+ cwd: cwd || process.cwd(),
53
+ env: Object.assign({}, process.env, env || {}),
54
+ timeout: 30000,
55
+ maxBuffer: 4 * 1024 * 1024,
56
+ }, function(err, stdout) {
57
+ if (err) { reject(err); return; }
58
+ try {
59
+ resolve(JSON.parse(String(stdout || "{}")));
60
+ } catch (e) {
61
+ reject(new Error("OpenCode returned invalid resolved configuration"));
62
+ }
63
+ });
64
+ });
65
+ }
66
+
67
+ function fetchOpenCodeAgentNames(binaryPath, cwd, env) {
68
+ return fetchOpenCodeResolvedConfig(binaryPath, cwd, env).then(function(config) {
69
+ return Object.keys(config.agent || {});
70
+ });
71
+ }
72
+
73
+ function isSafeOpenCodePermission(value) {
74
+ if (value === "ask" || value === "deny") return true;
75
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
76
+ var keys = Object.keys(value);
77
+ if (!keys.length) return false;
78
+ for (var i = 0; i < keys.length; i++) {
79
+ if (!isSafeOpenCodePermission(value[keys[i]])) return false;
80
+ }
81
+ return true;
82
+ }
83
+
84
+ function validateOpenCodeConfig(config) {
85
+ if (!config || config.permission !== "ask") {
86
+ throw new Error("OpenCode resolved configuration does not preserve global ask permissions");
87
+ }
88
+ var agents = config.agent || {};
89
+ var names = Object.keys(agents);
90
+ for (var i = 0; i < names.length; i++) {
91
+ var agent = agents[names[i]] || {};
92
+ if (agent.permission === undefined) continue;
93
+ if (!isSafeOpenCodePermission(agent.permission)) {
94
+ throw new Error("OpenCode agent has unsafe resolved permissions: " + names[i]);
95
+ }
96
+ }
97
+ }
98
+
99
+ var ACP_AGENT_PROFILES = {
100
+ gemini: {
101
+ vendor: "gemini",
102
+ displayName: "Gemini CLI",
103
+ binaryName: "gemini",
104
+ overrideName: "GEMINI_CLI_PATH",
105
+ args: ["--acp", "--approval-mode=default"],
106
+ defaultModels: ["auto"],
107
+ defaultModel: "auto",
108
+ // Gemini advertises loadSession, but releases through 0.46.0 could load
109
+ // the transcript without restoring the model's conversation memory.
110
+ // Keep resume gated until Clay's live contract test proves it reliable.
111
+ sessionResume: false,
112
+ permissionModeGuaranteed: true,
113
+ },
114
+ opencode: {
115
+ vendor: "opencode",
116
+ displayName: "OpenCode",
117
+ binaryName: "opencode",
118
+ overrideName: "OPENCODE_CLI_PATH",
119
+ args: ["acp"],
120
+ defaultModels: ["auto"],
121
+ defaultModel: "auto",
122
+ sessionResume: null,
123
+ fetchModels: fetchOpenCodeModels,
124
+ permissionModeGuaranteed: true,
125
+ prepare: function(ctx) {
126
+ var injected = ctx.initOpts && ctx.initOpts._openCodeAgentNames;
127
+ if (Array.isArray(injected)) {
128
+ ctx.driverState.agentNames = injected.slice();
129
+ return;
130
+ }
131
+ return fetchOpenCodeAgentNames(ctx.binaryPath, ctx.cwd, ctx.initOpts && ctx.initOpts.env).then(function(names) {
132
+ ctx.driverState.agentNames = names;
133
+ });
134
+ },
135
+ buildProcessOptions: function(ctx, base) {
136
+ var existing = (base.env && base.env.OPENCODE_CONFIG_CONTENT) || process.env.OPENCODE_CONFIG_CONTENT;
137
+ var config = {};
138
+ if (existing) config = JSON.parse(existing);
139
+ var agent = Object.assign({}, config.agent || {});
140
+ var names = ["build", "plan"].concat(ctx.driverState.agentNames || []);
141
+ for (var i = 0; i < names.length; i++) {
142
+ agent[names[i]] = Object.assign({}, agent[names[i]] || {}, { permission: "ask" });
143
+ }
144
+ base.env = Object.assign({}, base.env || {}, {
145
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(Object.assign({}, config, { permission: "ask", agent: agent })),
146
+ });
147
+ return base;
148
+ },
149
+ validateProcessOptions: function(ctx) {
150
+ var injected = ctx.initOpts && ctx.initOpts._openCodeResolvedConfig;
151
+ var resolved = injected
152
+ ? Promise.resolve(injected)
153
+ : fetchOpenCodeResolvedConfig(ctx.binaryPath, ctx.cwd, ctx.processOptions.env);
154
+ return resolved.then(function(config) {
155
+ validateOpenCodeConfig(config);
156
+ });
157
+ },
158
+ ensureSafePermissionMode: function() {
159
+ // OpenCode ACP modes select agents, not approval policy. The process
160
+ // configuration above enforces ask at both global and effective-agent levels.
161
+ return Promise.resolve();
162
+ },
163
+ },
164
+ };
165
+
166
+ function getAcpAgentProfile(vendor) {
167
+ return ACP_AGENT_PROFILES[vendor] || null;
168
+ }
169
+
170
+ function getAcpAgentDriver(vendor) {
171
+ return getAcpAgentProfile(vendor);
172
+ }
173
+
174
+ function findAcpAgentPath(profile) {
175
+ if (!profile) return null;
176
+ return findOnPath(profile.binaryName, profile.overrideName);
177
+ }
178
+
179
+ module.exports = {
180
+ ACP_AGENT_PROFILES: ACP_AGENT_PROFILES,
181
+ getAcpAgentProfile: getAcpAgentProfile,
182
+ getAcpAgentDriver: getAcpAgentDriver,
183
+ findAcpAgentPath: findAcpAgentPath,
184
+ fetchOpenCodeModels: fetchOpenCodeModels,
185
+ fetchOpenCodeAgentNames: fetchOpenCodeAgentNames,
186
+ fetchOpenCodeResolvedConfig: fetchOpenCodeResolvedConfig,
187
+ validateOpenCodeConfig: validateOpenCodeConfig,
188
+ };
@@ -0,0 +1,50 @@
1
+ // ACP Vendor Driver Runtime
2
+ // -------------------------
3
+ // ACP supplies defaults. Trusted vendor drivers may extend or replace them so
4
+ // the shared protocol never becomes the ceiling of the richer YOKE contract.
5
+
6
+ function hasHook(driver, name) {
7
+ return !!(driver && typeof driver[name] === "function");
8
+ }
9
+
10
+ function call(driver, name, context, fallback) {
11
+ if (hasHook(driver, name)) return driver[name](context, fallback);
12
+ return fallback ? fallback() : undefined;
13
+ }
14
+
15
+ function callAsync(driver, name, context, fallback) {
16
+ try {
17
+ return Promise.resolve(call(driver, name, context, fallback));
18
+ } catch (e) {
19
+ return Promise.reject(e);
20
+ }
21
+ }
22
+
23
+ function mergeCapabilities(driver, context, base) {
24
+ var defaults = Object.assign({}, base);
25
+ if (!hasHook(driver, "extendCapabilities")) return defaults;
26
+ var extended = driver.extendCapabilities(context, Object.assign({}, defaults));
27
+ return Object.assign({}, defaults, extended || {});
28
+ }
29
+
30
+ function buildParams(driver, hookName, context, base) {
31
+ var defaults = Object.assign({}, base);
32
+ if (!hasHook(driver, hookName)) return defaults;
33
+ var result = driver[hookName](context, Object.assign({}, defaults));
34
+ return result === undefined || result === null ? defaults : result;
35
+ }
36
+
37
+ function normalizeEvents(driver, context, fallback) {
38
+ var result = call(driver, "normalizeUpdate", context, fallback);
39
+ if (result === undefined || result === null) return [];
40
+ return Array.isArray(result) ? result : [result];
41
+ }
42
+
43
+ module.exports = {
44
+ hasHook: hasHook,
45
+ call: call,
46
+ callAsync: callAsync,
47
+ mergeCapabilities: mergeCapabilities,
48
+ buildParams: buildParams,
49
+ normalizeEvents: normalizeEvents,
50
+ };