clay-server 2.47.0-beta.6 → 3.0.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 (46) hide show
  1. package/lib/daemon.js +6 -27
  2. package/lib/project-connection.js +21 -8
  3. package/lib/project-session-pair.js +312 -0
  4. package/lib/project-session-spawn.js +8 -4
  5. package/lib/project-sessions.js +108 -10
  6. package/lib/project-user-message.js +18 -8
  7. package/lib/project.js +33 -0
  8. package/lib/public/app.js +20 -0
  9. package/lib/public/css/menus.css +83 -1
  10. package/lib/public/css/messages.css +14 -0
  11. package/lib/public/css/pane.css +420 -0
  12. package/lib/public/css/sidebar.css +40 -0
  13. package/lib/public/css/sticky-notes.css +2 -0
  14. package/lib/public/index.html +4 -0
  15. package/lib/public/modules/app-connection.js +6 -0
  16. package/lib/public/modules/app-favicon.js +5 -0
  17. package/lib/public/modules/app-header.js +101 -6
  18. package/lib/public/modules/app-messages.js +59 -6
  19. package/lib/public/modules/app-panels.js +49 -27
  20. package/lib/public/modules/app-rendering.js +11 -4
  21. package/lib/public/modules/pane-bridge.js +41 -0
  22. package/lib/public/modules/pane-session.js +18 -0
  23. package/lib/public/modules/sidebar-sessions.js +175 -1
  24. package/lib/public/modules/sidebar.js +2 -0
  25. package/lib/public/modules/split-group-helpers.js +18 -0
  26. package/lib/public/modules/split-pair-ui.js +324 -0
  27. package/lib/public/modules/split-view.js +499 -0
  28. package/lib/public/modules/sticky-notes.js +32 -91
  29. package/lib/public/style.css +1 -0
  30. package/lib/sdk-bridge.js +111 -20
  31. package/lib/sdk-message-processor.js +2 -2
  32. package/lib/server.js +24 -4
  33. package/lib/session-hygiene.js +100 -0
  34. package/lib/session-pair-mcp-server.js +28 -0
  35. package/lib/session-split-groups.js +298 -0
  36. package/lib/sessions.js +163 -34
  37. package/lib/ws-request.js +14 -0
  38. package/lib/ws-schema.js +13 -0
  39. package/lib/yoke/adapters/claude.js +32 -0
  40. package/lib/yoke/adapters/codex.js +100 -77
  41. package/lib/yoke/adapters/kiro.js +34 -35
  42. package/lib/yoke/codex-app-server.js +56 -6
  43. package/lib/yoke/index.js +1 -0
  44. package/lib/yoke/skill-discovery.js +196 -0
  45. package/lib/yoke/vendor-registry.js +34 -0
  46. package/package.json +2 -2
@@ -110,7 +110,10 @@ function CodexAppServer(executablePath, opts) {
110
110
  this.rl = null;
111
111
  this.nextId = 1;
112
112
  this.pendingRequests = {}; // id -> { resolve, reject, timer }
113
- this.eventHandler = null; // function(notification) for server-initiated events
113
+ // One app-server process is shared by every Codex session in a project.
114
+ // Route server-initiated events by threadId so a temporary query (such as
115
+ // title generation) cannot replace the handler for a long-lived session.
116
+ this.handlers = []; // [{ threadId, fn }]
114
117
  this.executablePath = executablePath || findCodexPath();
115
118
  this.opts = opts || {};
116
119
  this.started = false;
@@ -215,20 +218,67 @@ CodexAppServer.prototype._handleMessage = function(msg) {
215
218
 
216
219
  // Server-initiated request (has id + method) or notification (has method, no id)
217
220
  if (msg.method) {
218
- if (this.eventHandler) {
219
- this.eventHandler(msg);
221
+ var params = msg.params || {};
222
+ var threadId = params.threadId
223
+ || (params.thread && params.thread.id)
224
+ || (params.turn && params.turn.threadId)
225
+ || null;
226
+ var targets;
227
+ if (threadId) {
228
+ targets = this.handlers.filter(function(handler) {
229
+ return handler.threadId === threadId;
230
+ });
220
231
  } else {
221
- console.log("[codex-app-server] Unhandled event:", msg.method);
232
+ // Process-wide events such as auth failures belong to every live query.
233
+ targets = this.handlers.slice();
222
234
  }
235
+
236
+ if (!targets.length) {
237
+ if (msg.id !== undefined && msg.id !== null) {
238
+ console.warn("[codex-app-server] No handler for request " + msg.method + " (thread=" + (threadId || "none") + "), rejecting");
239
+ this.respondError(msg.id, -32001, "No active handler for thread " + (threadId || "none"));
240
+ } else {
241
+ console.log("[codex-app-server] Unhandled event:", msg.method);
242
+ }
243
+ return;
244
+ }
245
+
246
+ // A JSON-RPC request must be answered exactly once. Notifications may fan
247
+ // out when they are process-wide and intentionally have no thread id.
248
+ if (msg.id !== undefined && msg.id !== null) {
249
+ try {
250
+ targets[0].fn(msg);
251
+ } catch (e) {
252
+ console.error("[codex-app-server] Handler threw for " + msg.method + ":", e && e.message ? e.message : e);
253
+ this.respondError(msg.id, -32000, "Handler error");
254
+ }
255
+ return;
256
+ }
257
+ targets.forEach(function(handler) {
258
+ try { handler.fn(msg); } catch (e) {
259
+ console.error("[codex-app-server] Handler threw for " + msg.method + ":", e && e.message ? e.message : e);
260
+ }
261
+ });
223
262
  }
224
263
  };
225
264
 
265
+ CodexAppServer.prototype.addHandler = function(fn) {
266
+ var entry = { threadId: null, fn: fn };
267
+ this.handlers.push(entry);
268
+ return entry;
269
+ };
270
+
271
+ CodexAppServer.prototype.removeHandler = function(entry) {
272
+ var index = this.handlers.indexOf(entry);
273
+ if (index !== -1) this.handlers.splice(index, 1);
274
+ };
275
+
226
276
  // The definitive "not logged in" signal from Codex is a 401 on its own
227
277
  // responses endpoint, which only appears on stderr (not as a JSON-RPC error).
228
278
  // When we see it, synthesize an error event so the adapter maps it to the
229
279
  // neutral auth_required yokeType. Deduped so a burst of 401 retries collapses.
230
280
  CodexAppServer.prototype._maybeSignalAuthError = function(line) {
231
- if (!this.eventHandler || !line || this._authSignalSent) return;
281
+ if (!this.handlers.length || !line || this._authSignalSent) return;
232
282
  var isAuth = /missing bearer|token[_ ]?revoked|invalidated oauth|please sign in again/i.test(line)
233
283
  || (/\b401\b/.test(line) && /unauthorized|responses|openai\.com|chatgpt\.com/i.test(line));
234
284
  if (!isAuth) return;
@@ -236,7 +286,7 @@ CodexAppServer.prototype._maybeSignalAuthError = function(line) {
236
286
  var self = this;
237
287
  setTimeout(function () { self._authSignalSent = false; }, 15000);
238
288
  try {
239
- this.eventHandler({ method: "error", params: { error: { codexErrorInfo: "unauthorized", message: line } } });
289
+ this._handleMessage({ method: "error", params: { error: { codexErrorInfo: "unauthorized", message: line } } });
240
290
  } catch (e) {}
241
291
  };
242
292
 
package/lib/yoke/index.js CHANGED
@@ -377,6 +377,7 @@ module.exports = {
377
377
  invalidateAuthCache: invalidateAuthCache,
378
378
  VENDOR_REGISTRY: vendorRegistry.VENDOR_REGISTRY,
379
379
  getVendorInfo: vendorRegistry.getVendorInfo,
380
+ clampEffort: vendorRegistry.clampEffort,
380
381
  TOOL_POLICIES: iface.TOOL_POLICIES,
381
382
  validateAdapter: iface.validateAdapter,
382
383
  validateQueryHandle: iface.validateQueryHandle,
@@ -0,0 +1,196 @@
1
+ // Cross-vendor skill discovery
2
+ // ----------------------------
3
+ // Skills remain in their vendor-owned directories. This module only builds a
4
+ // merged view so every YOKE adapter can expose the same installed skills.
5
+
6
+ var fs = require("fs");
7
+ var os = require("os");
8
+ var path = require("path");
9
+
10
+ function defaultHomeDir() {
11
+ try { return require("../config").REAL_HOME; } catch (e) { return os.homedir(); }
12
+ }
13
+
14
+ function unquote(value) {
15
+ var text = String(value || "").trim();
16
+ if ((text[0] === '"' && text[text.length - 1] === '"') ||
17
+ (text[0] === "'" && text[text.length - 1] === "'")) {
18
+ return text.slice(1, -1);
19
+ }
20
+ return text;
21
+ }
22
+
23
+ function readMetadata(skillPath, fallbackName) {
24
+ var content;
25
+ try { content = fs.readFileSync(skillPath, "utf8"); } catch (e) { return null; }
26
+ var name = fallbackName;
27
+ var description = "";
28
+ var frontmatter = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
29
+ if (frontmatter) {
30
+ var nameMatch = frontmatter[1].match(/^name:\s*(.+)$/m);
31
+ var descriptionMatch = frontmatter[1].match(/^description:\s*(.+)$/m);
32
+ if (nameMatch) name = unquote(nameMatch[1]) || fallbackName;
33
+ if (descriptionMatch) description = unquote(descriptionMatch[1]);
34
+ }
35
+ if (!description) {
36
+ var heading = content.match(/^#\s+(.+)$/m);
37
+ if (heading) description = heading[1].trim();
38
+ }
39
+ return { name: name, description: description };
40
+ }
41
+
42
+ function scanRoot(root, source, merged) {
43
+ var entries;
44
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (e) { return; }
45
+ entries.sort(function(a, b) { return a.name.localeCompare(b.name); });
46
+ for (var i = 0; i < entries.length; i++) {
47
+ var entry = entries[i];
48
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
49
+ var skillPath = path.join(root, entry.name, "SKILL.md");
50
+ var metadata = readMetadata(skillPath, entry.name);
51
+ if (!metadata) continue;
52
+ merged[metadata.name] = {
53
+ name: metadata.name,
54
+ path: skillPath,
55
+ description: metadata.description,
56
+ source: source,
57
+ };
58
+ }
59
+ }
60
+
61
+ function getSkillRoots(cwd, opts) {
62
+ var homeDir = (opts && opts.homeDir) || defaultHomeDir();
63
+ var roots = [
64
+ { path: path.join(homeDir, ".codex", "skills"), source: "codex-user" },
65
+ { path: path.join(homeDir, ".claude", "skills"), source: "claude-user" },
66
+ ];
67
+ if (cwd) {
68
+ roots.push({ path: path.join(cwd, "skills"), source: "project" });
69
+ roots.push({ path: path.join(cwd, ".claude", "skills"), source: "claude-project" });
70
+ }
71
+ return roots;
72
+ }
73
+
74
+ function discoverSkills(cwd, opts) {
75
+ var merged = {};
76
+ var roots = getSkillRoots(cwd, opts);
77
+ for (var i = 0; i < roots.length; i++) scanRoot(roots[i].path, roots[i].source, merged);
78
+ return Object.keys(merged).sort().map(function(name) { return merged[name]; });
79
+ }
80
+
81
+ function indexSkills(skills) {
82
+ var indexed = {};
83
+ for (var i = 0; i < skills.length; i++) indexed[skills[i].name] = skills[i];
84
+ return indexed;
85
+ }
86
+
87
+ function findSkillReferences(text, skills) {
88
+ if (typeof text !== "string") return [];
89
+ var indexed = Array.isArray(skills) ? indexSkills(skills) : skills;
90
+ var found = [];
91
+ var seen = {};
92
+ var match;
93
+ var pattern = /\$([a-zA-Z0-9_-]+)/g;
94
+ while ((match = pattern.exec(text)) !== null) {
95
+ var skill = indexed[match[1]];
96
+ if (!skill || seen[skill.name]) continue;
97
+ seen[skill.name] = true;
98
+ found.push(skill);
99
+ }
100
+ return found;
101
+ }
102
+
103
+ function buildSkillIndex(skills) {
104
+ if (!skills.length) return "";
105
+ var lines = [
106
+ "Available shared skills. When a task matches a description, read that SKILL.md with your file tools before proceeding. A user may also invoke one with $skill-name:",
107
+ ];
108
+ for (var i = 0; i < skills.length; i++) {
109
+ var suffix = skills[i].description ? " — " + skills[i].description.replace(/\s+/g, " ") : "";
110
+ lines.push("- $" + skills[i].name + suffix + " — " + skills[i].path);
111
+ }
112
+ return lines.join("\n");
113
+ }
114
+
115
+ function readReferencedSkills(text, skills, maxChars) {
116
+ var references = findSkillReferences(text, skills);
117
+ var remaining = maxChars || 32768;
118
+ var blocks = [];
119
+ for (var i = 0; i < references.length && remaining > 0; i++) {
120
+ var content;
121
+ try { content = fs.readFileSync(references[i].path, "utf8"); } catch (e) { continue; }
122
+ var header = "<shared-skill name=\"" + references[i].name + "\" source=\"" + references[i].source + "\">\n";
123
+ var footer = "\n</shared-skill>";
124
+ var available = Math.max(0, remaining - header.length - footer.length);
125
+ if (!available) break;
126
+ if (content.length > available) {
127
+ var marker = "\n[Skill truncated by Clay]";
128
+ content = content.slice(0, Math.max(0, available - marker.length)) + marker.slice(0, available);
129
+ }
130
+ var block = header + content + footer;
131
+ blocks.push(block);
132
+ remaining -= block.length;
133
+ }
134
+ return blocks.join("\n\n");
135
+ }
136
+
137
+ function lstatOrNull(target) {
138
+ try { return fs.lstatSync(target); } catch (e) {
139
+ if (e.code === "ENOENT") return null;
140
+ throw e;
141
+ }
142
+ }
143
+
144
+ function ensureClaudeCodexPlugin(opts) {
145
+ var homeDir = (opts && opts.homeDir) || defaultHomeDir();
146
+ var source = path.join(homeDir, ".codex", "skills");
147
+ try {
148
+ if (!fs.statSync(source).isDirectory()) return null;
149
+ } catch (e) { return null; }
150
+
151
+ var pluginDir = path.join(homeDir, ".clay", "skill-bridge", "codex");
152
+ var bridgeRoot = path.dirname(pluginDir);
153
+ var manifestDir = path.join(pluginDir, ".claude-plugin");
154
+ var manifestPath = path.join(manifestDir, "plugin.json");
155
+ var skillsLink = path.join(pluginDir, "skills");
156
+ var manifest = JSON.stringify({ name: "codex", version: "1.0.0", description: "Codex skills shared by Clay" }, null, 2) + "\n";
157
+
158
+ try {
159
+ fs.mkdirSync(path.join(homeDir, ".clay"), { recursive: true });
160
+ var bridgeStat = lstatOrNull(bridgeRoot);
161
+ if (bridgeStat && (!bridgeStat.isDirectory() || bridgeStat.isSymbolicLink())) return null;
162
+ if (!bridgeStat) fs.mkdirSync(bridgeRoot);
163
+ var pluginStat = lstatOrNull(pluginDir);
164
+ if (pluginStat && (!pluginStat.isDirectory() || pluginStat.isSymbolicLink())) return null;
165
+ if (!pluginStat) fs.mkdirSync(pluginDir);
166
+ var manifestDirStat = lstatOrNull(manifestDir);
167
+ if (manifestDirStat && (!manifestDirStat.isDirectory() || manifestDirStat.isSymbolicLink())) return null;
168
+ if (!manifestDirStat) fs.mkdirSync(manifestDir);
169
+ var manifestStat = lstatOrNull(manifestPath);
170
+ if (manifestStat) {
171
+ if (!manifestStat.isFile() || fs.readFileSync(manifestPath, "utf8") !== manifest) return null;
172
+ } else {
173
+ fs.writeFileSync(manifestPath, manifest, { mode: 0o600, flag: "wx" });
174
+ }
175
+ var stat = lstatOrNull(skillsLink);
176
+ if (stat) {
177
+ if (!stat.isSymbolicLink() || path.resolve(pluginDir, fs.readlinkSync(skillsLink)) !== path.resolve(source)) return null;
178
+ } else {
179
+ fs.symlinkSync(source, skillsLink, "dir");
180
+ }
181
+ } catch (e) {
182
+ if (e.code !== "ENOENT") console.error("[yoke/skills] Failed to create Claude Codex skill bridge:", e.message);
183
+ return null;
184
+ }
185
+ return pluginDir;
186
+ }
187
+
188
+ module.exports = {
189
+ buildSkillIndex: buildSkillIndex,
190
+ discoverSkills: discoverSkills,
191
+ ensureClaudeCodexPlugin: ensureClaudeCodexPlugin,
192
+ findSkillReferences: findSkillReferences,
193
+ getSkillRoots: getSkillRoots,
194
+ indexSkills: indexSkills,
195
+ readReferencedSkills: readReferencedSkills,
196
+ };
@@ -2,6 +2,11 @@
2
2
  // needs metadata before adapter initialization should read it from here.
3
3
  // Do not import adapters into this module.
4
4
 
5
+ // Canonical effort ordering used to clamp a level onto a vendor that does
6
+ // not support it (e.g. codex "minimal" -> claude "low", claude "max" ->
7
+ // codex "xhigh").
8
+ var EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh", "max"];
9
+
5
10
  var VENDOR_REGISTRY = {
6
11
  claude: {
7
12
  displayName: "Claude Code",
@@ -9,6 +14,7 @@ var VENDOR_REGISTRY = {
9
14
  binaryName: "claude",
10
15
  avatar: "/claude-code-avatar.png",
11
16
  sessionModes: ["gui", "tui"],
17
+ effortLevels: ["low", "medium", "high", "xhigh", "max"],
12
18
  osUserIsolation: true,
13
19
  usageDashboard: {
14
20
  icon: "/claude-code-avatar.png",
@@ -24,6 +30,7 @@ var VENDOR_REGISTRY = {
24
30
  binaryName: "codex",
25
31
  avatar: "/codex-avatar.png",
26
32
  sessionModes: ["gui"],
33
+ effortLevels: ["minimal", "low", "medium", "high", "xhigh"],
27
34
  osUserIsolation: true,
28
35
  usageDashboard: {
29
36
  icon: "/codex-avatar.png",
@@ -39,6 +46,7 @@ var VENDOR_REGISTRY = {
39
46
  binaryName: "kiro-cli",
40
47
  avatar: "/kiro-avatar.svg",
41
48
  sessionModes: ["gui"],
49
+ effortLevels: [],
42
50
  osUserIsolation: false,
43
51
  usageDashboard: null,
44
52
  rateLimitTracking: false,
@@ -49,7 +57,33 @@ function getVendorInfo(vendor) {
49
57
  return VENDOR_REGISTRY[vendor] || null;
50
58
  }
51
59
 
60
+ // Clamp an effort level onto what the vendor supports. Returns the level
61
+ // itself when supported, the nearest supported level by the canonical order
62
+ // otherwise, and undefined when the vendor has no effort concept (or the
63
+ // input is not a known level -- never forward junk to an adapter).
64
+ function clampEffort(vendor, effort) {
65
+ if (!effort) return undefined;
66
+ var info = VENDOR_REGISTRY[vendor];
67
+ var levels = (info && info.effortLevels) || [];
68
+ if (levels.length === 0) return undefined;
69
+ if (levels.indexOf(effort) !== -1) return effort;
70
+ var position = EFFORT_ORDER.indexOf(effort);
71
+ if (position === -1) return undefined;
72
+ var nearest = levels[0];
73
+ var nearestDistance = Infinity;
74
+ for (var i = 0; i < levels.length; i++) {
75
+ var distance = Math.abs(EFFORT_ORDER.indexOf(levels[i]) - position);
76
+ if (distance < nearestDistance) {
77
+ nearestDistance = distance;
78
+ nearest = levels[i];
79
+ }
80
+ }
81
+ return nearest;
82
+ }
83
+
52
84
  module.exports = {
53
85
  VENDOR_REGISTRY: VENDOR_REGISTRY,
54
86
  getVendorInfo: getVendorInfo,
87
+ clampEffort: clampEffort,
88
+ EFFORT_ORDER: EFFORT_ORDER,
55
89
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.47.0-beta.6",
3
+ "version": "3.0.0-beta.2",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",
@@ -53,7 +53,7 @@
53
53
  "@lydell/node-pty": "^1.2.0-beta.3",
54
54
  "@openai/codex": "^0.147.0",
55
55
  "imapflow": "^1.3.1",
56
- "nodemailer": "^6.10.1",
56
+ "nodemailer": "^9.0.5",
57
57
  "qrcode-terminal": "^0.12.0",
58
58
  "web-push": "^3.6.7",
59
59
  "ws": "^8.18.0"