clay-server 3.3.2-beta.2 → 3.4.0-beta.1

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.
@@ -42,6 +42,7 @@ function getToolDefs(onPropose) {
42
42
  format: { type: "string", description: "Debate format, e.g. free_discussion (default)" },
43
43
  context: { type: "string", description: "Key context from the conversation that panelists should know" },
44
44
  specialRequests: { type: "string", description: "Special instructions for the debate, or empty" },
45
+ moderatorId: { type: "string", description: "Mate ID for the moderator. Required when proposing from a normal project; Mate sessions moderate their own proposals." },
45
46
  panelists: { type: "string", description: "JSON array of panelist objects: [{\"mateId\": \"<UUID>\", \"role\": \"perspective\", \"brief\": \"guidance\"}]" },
46
47
  }, ["topic", "panelists"]),
47
48
  handler: function (args) {
@@ -51,6 +52,13 @@ function getToolDefs(onPropose) {
51
52
  } catch (e) {
52
53
  return Promise.resolve({
53
54
  content: [{ type: "text", text: "Error: panelists must be a valid JSON array. Got: " + (args.panelists || "").substring(0, 100) }],
55
+ isError: true,
56
+ });
57
+ }
58
+ if (!Array.isArray(panelists)) {
59
+ return Promise.resolve({
60
+ content: [{ type: "text", text: "Error: panelists must be a JSON array." }],
61
+ isError: true,
54
62
  });
55
63
  }
56
64
 
@@ -59,6 +67,7 @@ function getToolDefs(onPropose) {
59
67
  format: args.format || "free_discussion",
60
68
  context: args.context || "",
61
69
  specialRequests: args.specialRequests || null,
70
+ moderatorId: args.moderatorId || null,
62
71
  panelists: panelists,
63
72
  };
64
73
 
@@ -66,6 +75,12 @@ function getToolDefs(onPropose) {
66
75
  if (result && result.action === "start") {
67
76
  return { content: [{ type: "text", text: "Debate approved and started. Topic: " + briefData.topic }] };
68
77
  }
78
+ if (result && result.action === "error") {
79
+ return {
80
+ content: [{ type: "text", text: "Error: " + (result.error || "The debate could not be started.") }],
81
+ isError: true,
82
+ };
83
+ }
69
84
  return { content: [{ type: "text", text: "Debate proposal was cancelled by the user." }] };
70
85
  });
71
86
  }
@@ -6,6 +6,22 @@ var emailAccounts = require("./email-accounts");
6
6
  var { getCodexConfig } = require("./codex-defaults");
7
7
  var yoke = require("./yoke");
8
8
 
9
+ function dispatchMessageSafely(handleMessage, sendTo, slug, ws, msg) {
10
+ try {
11
+ handleMessage(ws, msg);
12
+ return true;
13
+ } catch (err) {
14
+ var detail = err && err.stack ? err.stack : (err && err.message ? err.message : String(err));
15
+ console.error("[project-connection] Message handler failed for " + slug + "/" + (msg.type || "unknown") + ":", detail);
16
+ try {
17
+ sendTo(ws, { type: "error", text: "The request could not be completed. Check the server logs for details." });
18
+ } catch (sendErr) {
19
+ console.error("[project-connection] Failed to send message error for " + slug + ":", sendErr && sendErr.message ? sendErr.message : sendErr);
20
+ }
21
+ return false;
22
+ }
23
+ }
24
+
9
25
  /**
10
26
  * Attach connection/disconnection handlers to a project context.
11
27
  *
@@ -286,7 +302,7 @@ function attachConnection(ctx) {
286
302
  ws.on("message", function (raw) {
287
303
  var msg;
288
304
  try { msg = JSON.parse(raw.toString()); } catch (e) { return; }
289
- handleMessage(ws, msg);
305
+ dispatchMessageSafely(handleMessage, sendTo, slug, ws, msg);
290
306
  });
291
307
 
292
308
  ws.on("close", function () {
@@ -317,4 +333,4 @@ function attachConnection(ctx) {
317
333
  };
318
334
  }
319
335
 
320
- module.exports = { attachConnection: attachConnection };
336
+ module.exports = { attachConnection: attachConnection, dispatchMessageSafely: dispatchMessageSafely };
@@ -0,0 +1,138 @@
1
+ var path = require("path");
2
+ var debateMcp = require("./debate-mcp-server");
3
+
4
+ function attachDebateProposal(ctx) {
5
+ var pendingProposals = {};
6
+
7
+ function createMcpServer(adapter, boundSession) {
8
+ var toolDefs = debateMcp.getToolDefs(function onPropose(briefData) {
9
+ if (!boundSession) {
10
+ return Promise.resolve({
11
+ action: "error",
12
+ error: "Debate proposals require an active Clay session.",
13
+ });
14
+ }
15
+
16
+ return new Promise(function (resolve) {
17
+ var proposalId = "dp_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
18
+ briefData.proposalId = proposalId;
19
+ pendingProposals[proposalId] = {
20
+ resolve: resolve,
21
+ briefData: briefData,
22
+ session: boundSession,
23
+ };
24
+ });
25
+ });
26
+
27
+ return adapter.createToolServer({
28
+ name: "clay-debate",
29
+ version: "1.0.0",
30
+ tools: toolDefs,
31
+ });
32
+ }
33
+
34
+ function findPendingProposal(ws, msg) {
35
+ var keys = Object.keys(pendingProposals);
36
+ if (keys.length === 0) return null;
37
+ var key = msg.proposalId || null;
38
+ if (!key && ws && Number.isInteger(ws._clayActiveSession)) {
39
+ for (var i = keys.length - 1; i >= 0; i--) {
40
+ var candidate = pendingProposals[keys[i]];
41
+ if (candidate.session && candidate.session.localId === ws._clayActiveSession) {
42
+ key = keys[i];
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ if (!key) key = keys[keys.length - 1];
48
+ var pending = pendingProposals[key];
49
+ if (!pending) return null;
50
+ delete pendingProposals[key];
51
+ return pending;
52
+ }
53
+
54
+ function rejectProposal(ws, pending, error) {
55
+ try {
56
+ ctx.sendTo(ws, { type: "debate_error", error: error });
57
+ } catch (sendErr) {
58
+ console.error("[debate] Failed to send proposal error:", sendErr && sendErr.message ? sendErr.message : sendErr);
59
+ }
60
+ pending.resolve({ action: "error", error: error });
61
+ }
62
+
63
+ function validateProposal(ws, pending) {
64
+ var briefData = pending.briefData;
65
+ var moderatorId = ctx.isMate ? path.basename(ctx.cwd) : briefData.moderatorId;
66
+ if (!moderatorId || typeof moderatorId !== "string") {
67
+ return { error: "A valid Mate moderator is required to start this debate." };
68
+ }
69
+
70
+ var userId = ws && ws._clayUser
71
+ ? ws._clayUser.id
72
+ : (pending.session.ownerId || ctx.getProjectOwnerId() || null);
73
+ var mateCtx = ctx.buildMateCtx(userId);
74
+ if (!ctx.getMate(mateCtx, moderatorId)) {
75
+ return { error: "The selected debate moderator is unavailable." };
76
+ }
77
+
78
+ var panelists = briefData.panelists;
79
+ if (!Array.isArray(panelists) || panelists.length === 0) {
80
+ return { error: "At least one valid panelist is required to start this debate." };
81
+ }
82
+
83
+ var seen = {};
84
+ for (var i = 0; i < panelists.length; i++) {
85
+ var panelistId = panelists[i] && panelists[i].mateId;
86
+ if (!panelistId || typeof panelistId !== "string" || !ctx.getMate(mateCtx, panelistId)) {
87
+ return { error: "One or more selected debate panelists are unavailable." };
88
+ }
89
+ if (panelistId === moderatorId) {
90
+ return { error: "The debate moderator cannot also be a panelist." };
91
+ }
92
+ if (seen[panelistId]) {
93
+ return { error: "The same Mate cannot be added to a debate more than once." };
94
+ }
95
+ seen[panelistId] = true;
96
+ }
97
+
98
+ return { moderatorId: moderatorId };
99
+ }
100
+
101
+ function handleMessage(ws, msg) {
102
+ if (msg.type !== "debate_proposal_response") return false;
103
+
104
+ var pending = findPendingProposal(ws, msg);
105
+ if (!pending) return true;
106
+ if (msg.action !== "start") {
107
+ pending.resolve({ action: "cancel" });
108
+ return true;
109
+ }
110
+
111
+ var validated = validateProposal(ws, pending);
112
+ if (validated.error) {
113
+ rejectProposal(ws, pending, validated.error);
114
+ return true;
115
+ }
116
+
117
+ try {
118
+ var result = ctx.startDebate(pending.session, pending.briefData, validated.moderatorId, ws);
119
+ if (result && result.error) {
120
+ rejectProposal(ws, pending, result.error);
121
+ return true;
122
+ }
123
+ pending.resolve({ action: "start" });
124
+ } catch (err) {
125
+ var detail = err && err.message ? err.message : String(err);
126
+ console.error("[debate] Failed to start approved proposal:", detail);
127
+ rejectProposal(ws, pending, "The debate could not be started. Check the server logs for details.");
128
+ }
129
+ return true;
130
+ }
131
+
132
+ return {
133
+ createMcpServer: createMcpServer,
134
+ handleMessage: handleMessage,
135
+ };
136
+ }
137
+
138
+ module.exports = { attachDebateProposal: attachDebateProposal };
@@ -1742,6 +1742,7 @@ function attachDebate(ctx) {
1742
1742
 
1743
1743
  console.log("[debate] MCP debate approved. Topic:", debate.topic, "debateId:", debateId);
1744
1744
  startDebateLive(session);
1745
+ return { ok: true };
1745
1746
  }
1746
1747
 
1747
1748
  // --- Public API ---
@@ -21,12 +21,22 @@ function attachFileWatch(ctx) {
21
21
  // one browser tab silently replace another tab's live preview subscription.
22
22
  var fileWatchers = new Map();
23
23
 
24
+ function settleFileWatchReady(entry, ready) {
25
+ if (!entry || !entry.resolveReady) return;
26
+ var resolve = entry.resolveReady;
27
+ entry.resolveReady = null;
28
+ resolve(ready);
29
+ }
30
+
24
31
  function closeFileWatch(key) {
25
32
  var entry = fileWatchers.get(key);
26
33
  if (!entry) return;
27
34
  clearTimeout(entry.debounce);
35
+ if (entry.reconcile) clearImmediate(entry.reconcile);
36
+ if (entry.pollTimer) clearInterval(entry.pollTimer);
28
37
  try { entry.watcher.close(); } catch (e) {}
29
38
  fileWatchers.delete(key);
39
+ settleFileWatchReady(entry, false);
30
40
  }
31
41
 
32
42
  function sendFileChanged(client, message) {
@@ -37,6 +47,36 @@ function attachFileWatch(ctx) {
37
47
  }
38
48
  }
39
49
 
50
+ function readFileSnapshot(absPath) {
51
+ var stat = fs.statSync(absPath);
52
+ var ext = path.extname(absPath).toLowerCase();
53
+ if (stat.size > FS_MAX_SIZE || BINARY_EXTS.has(ext)) return null;
54
+ return { content: fs.readFileSync(absPath, "utf8"), size: stat.size };
55
+ }
56
+
57
+ function publishFileChanged(key, client, relPath, absPath) {
58
+ var latest = fileWatchers.get(key);
59
+ if (!latest || latest.relPath !== relPath) return;
60
+ try {
61
+ var snapshot = readFileSnapshot(absPath);
62
+ if (!snapshot) return;
63
+ if (latest.hasSnapshot && latest.content === snapshot.content && latest.size === snapshot.size) return;
64
+ latest.hasSnapshot = true;
65
+ latest.content = snapshot.content;
66
+ latest.size = snapshot.size;
67
+ sendFileChanged(client, {
68
+ type: "fs_file_changed",
69
+ path: relPath,
70
+ content: snapshot.content,
71
+ size: snapshot.size,
72
+ });
73
+ } catch (e) {
74
+ // Atomic saves can briefly remove the destination path between rename
75
+ // events. Keep the parent watcher alive for the next event.
76
+ if (e.code !== "ENOENT") closeFileWatch(key);
77
+ }
78
+ }
79
+
40
80
  function startFileWatch(client, relPath) {
41
81
  // Preserve the old single-argument API for callers outside the websocket
42
82
  // file browser. They share one legacy subscription.
@@ -45,10 +85,10 @@ function attachFileWatch(ctx) {
45
85
  client = null;
46
86
  }
47
87
  var absPath = safePath(cwd, relPath);
48
- if (!absPath) return;
88
+ if (!absPath) return Promise.resolve(false);
49
89
  var key = client || "_legacy";
50
90
  var existing = fileWatchers.get(key);
51
- if (existing && existing.relPath === relPath) return;
91
+ if (existing && existing.relPath === relPath) return existing.ready;
52
92
  closeFileWatch(key);
53
93
 
54
94
  // Watch the parent directory rather than the file inode. Editors and agent
@@ -56,6 +96,8 @@ function attachFileWatch(ctx) {
56
96
  // misses later edits even though the path still exists.
57
97
  var parentPath = path.dirname(absPath);
58
98
  var baseName = path.basename(absPath);
99
+ var initialSnapshot = null;
100
+ try { initialSnapshot = readFileSnapshot(absPath); } catch (e) {}
59
101
  try {
60
102
  var watcher = fs.watch(parentPath, function (eventType, filename) {
61
103
  if (filename && String(filename) !== baseName) return;
@@ -63,25 +105,44 @@ function attachFileWatch(ctx) {
63
105
  if (!active || active.relPath !== relPath) return;
64
106
  clearTimeout(active.debounce);
65
107
  active.debounce = setTimeout(function () {
66
- var latest = fileWatchers.get(key);
67
- if (!latest || latest.relPath !== relPath) return;
68
- try {
69
- var stat = fs.statSync(absPath);
70
- var ext = path.extname(absPath).toLowerCase();
71
- if (stat.size > FS_MAX_SIZE || BINARY_EXTS.has(ext)) return;
72
- var content = fs.readFileSync(absPath, "utf8");
73
- sendFileChanged(client, { type: "fs_file_changed", path: relPath, content: content, size: stat.size });
74
- } catch (e) {
75
- // Atomic saves can briefly remove the destination path between
76
- // rename events. Keep the parent watcher alive for the next event.
77
- if (e.code !== "ENOENT") closeFileWatch(key);
78
- }
108
+ publishFileChanged(key, client, relPath, absPath);
79
109
  }, 200);
80
110
  });
81
- fileWatchers.set(key, { watcher: watcher, relPath: relPath, debounce: null });
111
+ var resolveReady = null;
112
+ var ready = new Promise(function (resolve) { resolveReady = resolve; });
113
+ var entry = {
114
+ watcher: watcher,
115
+ relPath: relPath,
116
+ debounce: null,
117
+ reconcile: null,
118
+ pollTimer: null,
119
+ ready: ready,
120
+ resolveReady: resolveReady,
121
+ hasSnapshot: !!initialSnapshot,
122
+ content: initialSnapshot ? initialSnapshot.content : null,
123
+ size: initialSnapshot ? initialSnapshot.size : null,
124
+ };
125
+ fileWatchers.set(key, entry);
126
+ // Directory events are the low-latency path, but macOS can coalesce or
127
+ // drop them under load. Periodic content reconciliation is the source of
128
+ // truth and also survives atomic replacements with identical metadata.
129
+ entry.pollTimer = setInterval(function () {
130
+ publishFileChanged(key, client, relPath, absPath);
131
+ }, 1000);
132
+ // fs.watch has no readiness event. Reconcile once on the next event-loop
133
+ // turn so a change between the initial read and native watcher activation
134
+ // cannot leave the browser showing stale content.
135
+ entry.reconcile = setImmediate(function () {
136
+ var active = fileWatchers.get(key);
137
+ if (active) active.reconcile = null;
138
+ publishFileChanged(key, client, relPath, absPath);
139
+ if (fileWatchers.get(key) === entry) settleFileWatchReady(entry, true);
140
+ });
82
141
  watcher.on("error", function () { closeFileWatch(key); });
142
+ return ready;
83
143
  } catch (e) {
84
144
  closeFileWatch(key);
145
+ return Promise.resolve(false);
85
146
  }
86
147
  }
87
148
 
package/lib/project.js CHANGED
@@ -16,6 +16,7 @@ var matesModule = require("./mates");
16
16
  var sessionSearch = require("./session-search");
17
17
  var userPresence = require("./user-presence");
18
18
  var { attachDebate } = require("./project-debate");
19
+ var { attachDebateProposal } = require("./project-debate-proposal");
19
20
  var { attachMemory } = require("./project-memory");
20
21
  var { attachMateInteraction } = require("./project-mate-interaction");
21
22
  var { attachUserMention } = require("./project-user-mention");
@@ -247,7 +248,6 @@ function createProjectContext(opts) {
247
248
  var clients = new Set();
248
249
 
249
250
  // --- Browser extension state (shared mutable object) ---
250
- var _pendingDebateProposals = {}; // proposalId -> { resolve, briefData }
251
251
  var _extToken = crypto.randomUUID(); // Auth token for MCP server bridge
252
252
  var browserState = {
253
253
  _browserTabList: {},
@@ -568,6 +568,19 @@ function createProjectContext(opts) {
568
568
  getLinuxUserForSession: getLinuxUserForSession,
569
569
  getPairToolDefs: function (boundSession) { return _sessionPair.getToolDefs(boundSession); },
570
570
  });
571
+ var _debate = null;
572
+ var _debateProposal = attachDebateProposal({
573
+ cwd: cwd,
574
+ isMate: isMate,
575
+ sendTo: sendTo,
576
+ buildMateCtx: matesModule.buildMateCtx,
577
+ getMate: matesModule.getMate,
578
+ getProjectOwnerId: function () { return projectOwnerId; },
579
+ startDebate: function (session, briefData, moderatorId, ws) {
580
+ if (!_debate) return { error: "The debate engine is unavailable." };
581
+ return _debate.handleMcpDebateApproval(session, briefData, moderatorId, ws);
582
+ },
583
+ });
571
584
 
572
585
  // --- MCP tool servers (created via YOKE adapter) ---
573
586
  var mcpServers = (function () {
@@ -605,20 +618,7 @@ function createProjectContext(opts) {
605
618
 
606
619
  // Debate MCP server (available to both mates and main project)
607
620
  try {
608
- var debateMcp = require("./debate-mcp-server");
609
- var debateToolDefs = debateMcp.getToolDefs(function onPropose(briefData) {
610
- return new Promise(function (resolve) {
611
- var proposalId = "dp_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
612
- briefData.proposalId = proposalId;
613
- _pendingDebateProposals[proposalId] = {
614
- resolve: resolve,
615
- briefData: briefData,
616
- };
617
- // The SDK sends tool_executing with briefData as input.
618
- // Client renders the debate brief card when it sees propose_debate.
619
- });
620
- });
621
- var debateMcpConfig = adapter.createToolServer({ name: "clay-debate", version: "1.0.0", tools: debateToolDefs });
621
+ var debateMcpConfig = _debateProposal.createMcpServer(adapter, null);
622
622
  if (debateMcpConfig) servers[debateMcpConfig.name || "clay-debate"] = debateMcpConfig;
623
623
  } catch (e) {
624
624
  console.error("[project] Failed to create debate MCP server:", e.message);
@@ -807,6 +807,15 @@ function createProjectContext(opts) {
807
807
  }
808
808
  continue;
809
809
  }
810
+ if (name === "clay-debate" && forSession) {
811
+ try {
812
+ var boundDebate = _debateProposal.createMcpServer(adapter, forSession);
813
+ if (boundDebate) { filtered[name] = boundDebate; hasAny = true; }
814
+ } catch (e) {
815
+ console.error("[project] Failed to bind debate MCP server:", e.message);
816
+ }
817
+ continue;
818
+ }
810
819
  filtered[name] = mcpServers[name];
811
820
  hasAny = true;
812
821
  }
@@ -1139,28 +1148,7 @@ function createProjectContext(opts) {
1139
1148
  handleDebateConfirmBrief(ws);
1140
1149
  return;
1141
1150
  }
1142
- if (msg.type === "debate_proposal_response") {
1143
- // Match the most recent pending proposal (proposalId may not be
1144
- // available on the client since it's not part of the tool input)
1145
- var _dpKeys = Object.keys(_pendingDebateProposals);
1146
- if (_dpKeys.length === 0) return;
1147
- var _dpKey = msg.proposalId || _dpKeys[_dpKeys.length - 1];
1148
- var pending = _pendingDebateProposals[_dpKey];
1149
- if (!pending) return;
1150
- delete _pendingDebateProposals[_dpKey];
1151
- if (msg.action === "start") {
1152
- // Set up debate state on the session, then transition to live
1153
- var _dpSession = getSessionForWs(ws);
1154
- if (_dpSession) {
1155
- var _dpMateId = isMate ? path.basename(cwd) : null;
1156
- handleMcpDebateApproval(_dpSession, pending.briefData, _dpMateId, ws);
1157
- }
1158
- pending.resolve({ action: "start" });
1159
- } else {
1160
- pending.resolve({ action: "cancel" });
1161
- }
1162
- return;
1163
- }
1151
+ if (_debateProposal.handleMessage(ws, msg)) return;
1164
1152
  if (msg.type === "debate_user_floor_response") {
1165
1153
  handleDebateUserFloorResponse(ws, msg);
1166
1154
  return;
@@ -1268,7 +1256,7 @@ function createProjectContext(opts) {
1268
1256
  var handleUserMention = _userMention.handleUserMention;
1269
1257
 
1270
1258
  // --- Debate engine (delegated to project-debate.js) ---
1271
- var _debate = attachDebate({
1259
+ _debate = attachDebate({
1272
1260
  cwd: cwd,
1273
1261
  slug: slug,
1274
1262
  isMate: isMate,
@@ -1297,7 +1285,6 @@ function createProjectContext(opts) {
1297
1285
  var handleDebateUserFloorResponse = _debate.handleDebateUserFloorResponse;
1298
1286
  var restoreDebateState = _debate.restoreDebateState;
1299
1287
  var checkForDmDebateBrief = _debate.checkForDmDebateBrief;
1300
- var handleMcpDebateApproval = _debate.handleMcpDebateApproval;
1301
1288
 
1302
1289
  // --- Session presence (who is viewing which session) ---
1303
1290
  function broadcastPresence() {
@@ -1547,7 +1534,7 @@ function createProjectContext(opts) {
1547
1534
  // In-app MCP servers (debate, browser, email).
1548
1535
  // Use getLocalMcpServers() so clay-browser is hidden unless the
1549
1536
  // Chrome extension is currently connected (see issue #325).
1550
- var localMcp = getLocalMcpServers();
1537
+ var localMcp = getLocalMcpServers(boundSession);
1551
1538
  if (localMcp) {
1552
1539
  var inAppNames = Object.keys(localMcp);
1553
1540
  for (var i = 0; i < inAppNames.length; i++) {
@@ -1599,7 +1586,7 @@ function createProjectContext(opts) {
1599
1586
  }
1600
1587
  if (sessionOnly) return Promise.reject(new Error("Session tool not found: " + serverName + "/" + toolName));
1601
1588
  // Try in-app servers first (gated by extension connectivity for clay-browser).
1602
- var localMcp = getLocalMcpServers();
1589
+ var localMcp = getLocalMcpServers(boundSession);
1603
1590
  if (localMcp && localMcp[serverName]) {
1604
1591
  var server = localMcp[serverName];
1605
1592
  if (server.instance && server.instance._registeredTools && server.instance._registeredTools[toolName]) {
@@ -799,6 +799,10 @@
799
799
  .vendor-toggle-label {
800
800
  pointer-events: none;
801
801
  }
802
+ @media (min-width: 901px) {
803
+ .vendor-toggle-btn:not(.active) .vendor-toggle-label { display: none; }
804
+ .vendor-toggle-btn:not(.active) { padding: 0 8px; }
805
+ }
802
806
  @media (max-width: 900px) {
803
807
  .vendor-toggle-label { display: none; }
804
808
  .vendor-toggle-btn { padding: 0 8px; }
@@ -682,6 +682,7 @@
682
682
  .mate-vendor-label {
683
683
  pointer-events: none;
684
684
  }
685
+ .mate-vendor-btn:not(.active) .mate-vendor-label { display: none; }
685
686
 
686
687
  /* Collapse button inside mate header: white to match header style */
687
688
  .mate-sidebar-header .sidebar-collapse-btn {
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Gemini CLI">
2
+ <defs>
3
+ <linearGradient id="g" x1="8" y1="56" x2="56" y2="8" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#4f7cff"/>
5
+ <stop offset="0.5" stop-color="#9b5cff"/>
6
+ <stop offset="1" stop-color="#ff65b3"/>
7
+ </linearGradient>
8
+ </defs>
9
+ <rect width="64" height="64" rx="16" fill="#10131d"/>
10
+ <path d="M32 8c2.3 14.1 9.9 21.7 24 24-14.1 2.3-21.7 9.9-24 24-2.3-14.1-9.9-21.7-24-24 14.1-2.3 21.7-9.9 24-24Z" fill="url(#g)"/>
11
+ </svg>
@@ -527,6 +527,14 @@
527
527
  <img src="/codex-avatar.png" class="vendor-toggle-icon" alt="Codex">
528
528
  <span class="vendor-toggle-label">Codex</span>
529
529
  </button>
530
+ <button id="vendor-btn-gemini" class="vendor-toggle-btn" data-vendor="gemini">
531
+ <img src="/gemini-avatar.svg" class="vendor-toggle-icon" alt="Gemini">
532
+ <span class="vendor-toggle-label">Gemini CLI</span>
533
+ </button>
534
+ <button id="vendor-btn-opencode" class="vendor-toggle-btn" data-vendor="opencode">
535
+ <img src="/opencode-avatar.svg" class="vendor-toggle-icon" alt="OpenCode">
536
+ <span class="vendor-toggle-label">OpenCode</span>
537
+ </button>
530
538
  <button id="vendor-btn-kiro" class="vendor-toggle-btn" data-vendor="kiro">
531
539
  <img src="/kiro-avatar.svg" class="vendor-toggle-icon" alt="Kiro">
532
540
  <span class="vendor-toggle-label">Kiro CLI</span>
@@ -89,6 +89,8 @@ var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
89
89
  var EFFORT_LEVELS_BY_VENDOR = {
90
90
  claude: ["low", "medium", "high", "xhigh", "max"],
91
91
  codex: ["minimal", "low", "medium", "high", "xhigh"],
92
+ gemini: [],
93
+ opencode: [],
92
94
  kiro: ["low", "medium", "high", "xhigh", "max"],
93
95
  };
94
96
  var THINKING_OPTIONS = ["disabled", "adaptive", "budget"];
@@ -441,8 +443,16 @@ export function initPanels() {
441
443
  var vendorToggleWrap = $("vendor-toggle-wrap");
442
444
  var vendorBtnClaude = $("vendor-btn-claude");
443
445
  var vendorBtnCodex = $("vendor-btn-codex");
446
+ var vendorBtnGemini = $("vendor-btn-gemini");
447
+ var vendorBtnOpenCode = $("vendor-btn-opencode");
444
448
  var vendorBtnKiro = $("vendor-btn-kiro");
445
- var vendorBtns = { claude: vendorBtnClaude, codex: vendorBtnCodex, kiro: vendorBtnKiro };
449
+ var vendorBtns = {
450
+ claude: vendorBtnClaude,
451
+ codex: vendorBtnCodex,
452
+ gemini: vendorBtnGemini,
453
+ opencode: vendorBtnOpenCode,
454
+ kiro: vendorBtnKiro,
455
+ };
446
456
 
447
457
  function updateVendorToggle() {
448
458
  var installed = store.get('installedVendors') || [];
@@ -471,6 +481,8 @@ export function initPanels() {
471
481
 
472
482
  if (vendorBtnClaude) vendorBtnClaude.addEventListener("click", function() { onVendorClick("claude"); });
473
483
  if (vendorBtnCodex) vendorBtnCodex.addEventListener("click", function() { onVendorClick("codex"); });
484
+ if (vendorBtnGemini) vendorBtnGemini.addEventListener("click", function() { onVendorClick("gemini"); });
485
+ if (vendorBtnOpenCode) vendorBtnOpenCode.addEventListener("click", function() { onVendorClick("opencode"); });
474
486
  if (vendorBtnKiro) vendorBtnKiro.addEventListener("click", function() { onVendorClick("kiro"); });
475
487
 
476
488
  // --- Reactive UI sync ---
@@ -19,21 +19,27 @@ import { getScheduledMsgEl } from './app-rate-limit.js';
19
19
  export var VENDOR_AVATARS = {
20
20
  claude: "/claude-code-avatar.png",
21
21
  codex: "/codex-avatar.png",
22
+ gemini: "/gemini-avatar.svg",
23
+ opencode: "/opencode-avatar.svg",
22
24
  kiro: "/kiro-avatar.svg",
23
25
  };
24
26
  export var VENDOR_NAMES = {
25
27
  claude: "Claude Code",
26
28
  codex: "Codex",
29
+ gemini: "Gemini CLI",
30
+ opencode: "OpenCode",
27
31
  kiro: "Kiro CLI",
28
32
  };
29
33
  // Display order for every vendor Clay knows about, installed or not. Pickers
30
34
  // render the full list so a missing CLI reads as "not installed yet" rather
31
35
  // than "Clay doesn't support it".
32
- export var VENDOR_ORDER = ["claude", "codex", "kiro"];
36
+ export var VENDOR_ORDER = ["claude", "codex", "gemini", "opencode", "kiro"];
33
37
  // Where to send the user when they pick a vendor whose CLI isn't installed.
34
38
  export var VENDOR_HOMEPAGES = {
35
39
  claude: "https://claude.com/product/claude-code",
36
40
  codex: "https://openai.com/codex/",
41
+ gemini: "https://github.com/google-gemini/gemini-cli",
42
+ opencode: "https://opencode.ai/",
37
43
  kiro: "https://kiro.dev/",
38
44
  };
39
45
  var NEW_MSG_BTN_DEFAULT = "\u2193 Latest";
@@ -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];
@@ -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,