clay-server 3.4.0 → 3.5.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.
@@ -1,5 +1,19 @@
1
1
  var yoke = require("./yoke");
2
2
  var contextBuilder = require("./session-handoff-context");
3
+ var sessionHandoffMcp = require("./session-handoff-mcp-server");
4
+
5
+ var MAX_HANDOFF_CHAIN_DEPTH = 5;
6
+
7
+ function toolResult(text) {
8
+ return Promise.resolve({ content: [{ type: "text", text: text }] });
9
+ }
10
+
11
+ function toolError(message) {
12
+ return Promise.resolve({
13
+ content: [{ type: "text", text: "Error: " + message }],
14
+ isError: true,
15
+ });
16
+ }
3
17
 
4
18
  function hasUserContext(session) {
5
19
  var history = (session && session.history) || [];
@@ -38,7 +52,7 @@ function attachSessionHandoff(ctx) {
38
52
  }
39
53
  var ownerId = ws._clayUser && ctx.usersModule.isMultiUser() ? ws._clayUser.id : null;
40
54
  if ((source.ownerId || null) !== ownerId) throw new Error("Session access denied.");
41
- return linuxUser;
55
+ return { linuxUser: linuxUser, vendorInfo: vendorInfo };
42
56
  }
43
57
 
44
58
  function quietRecord(session, entry) {
@@ -50,9 +64,9 @@ function attachSessionHandoff(ctx) {
50
64
  var source = sm.sessions.get(ws._clayActiveSession) || null;
51
65
  var targetVendor = typeof msg.targetVendor === "string" ? msg.targetVendor.trim() : "";
52
66
  var targetModel = typeof msg.model === "string" ? msg.model.trim() : "";
53
- var linuxUser;
67
+ var validation;
54
68
  try {
55
- linuxUser = validate(ws, source, targetVendor);
69
+ validation = validate(ws, source, targetVendor);
56
70
  } catch (err) {
57
71
  sendResult(ws, false, { error: err.message || String(err) });
58
72
  return;
@@ -102,6 +116,7 @@ function attachSessionHandoff(ctx) {
102
116
  cwd: ctx.cwd,
103
117
  source: source,
104
118
  targetVendor: targetVendor,
119
+ sourceReadTool: (validation.vendorInfo || {}).sessionBoundTools !== false,
105
120
  });
106
121
  target.isProcessing = true;
107
122
  target.lastActivity = Date.now();
@@ -128,7 +143,7 @@ function attachSessionHandoff(ctx) {
128
143
  target._queryStartTs = Date.now();
129
144
  var startPromise;
130
145
  try {
131
- startPromise = sdk.startQuery(target, prompt, undefined, linuxUser);
146
+ startPromise = sdk.startQuery(target, prompt, undefined, validation.linuxUser);
132
147
  } catch (err) {
133
148
  failStart(err);
134
149
  return;
@@ -153,7 +168,111 @@ function attachSessionHandoff(ctx) {
153
168
  return false;
154
169
  }
155
170
 
156
- return { handleMessage: handleMessage };
171
+ function resolveSourceChain(boundSession) {
172
+ var chain = [];
173
+ var visited = new Set();
174
+ var sourceSessionId = boundSession.handoff.sourceSessionId;
175
+ var ownerId = boundSession.ownerId || null;
176
+ for (var depth = 0; depth < MAX_HANDOFF_CHAIN_DEPTH; depth++) {
177
+ var visitKey = String(sourceSessionId);
178
+ if (visited.has(visitKey)) break;
179
+ visited.add(visitKey);
180
+ var source = sm.sessions.get(sourceSessionId);
181
+ if (!source) return { error: "Source session was not found: " + sourceSessionId };
182
+ if ((source.ownerId || null) !== ownerId) return { error: "Source session owner does not match this session" };
183
+ chain.push(source);
184
+ if (!source.handoff || source.handoff.sourceSessionId === undefined || source.handoff.sourceSessionId === null) break;
185
+ sourceSessionId = source.handoff.sourceSessionId;
186
+ }
187
+ return { chain: chain };
188
+ }
189
+
190
+ function findSource(chain, requestedId) {
191
+ if (requestedId === undefined || requestedId === null || requestedId === "") return chain[0] || null;
192
+ for (var i = 0; i < chain.length; i++) {
193
+ if (String(chain[i].localId) === String(requestedId)) return chain[i];
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function boundedInteger(value, fallback, min, max) {
199
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
200
+ return Math.min(max, Math.max(min, Math.floor(value)));
201
+ }
202
+
203
+ function formatHistory(source, args) {
204
+ var history = Array.isArray(source.history) ? source.history : [];
205
+ var limit = boundedInteger(args.limit, 30, 1, 100);
206
+ var offset = typeof args.offset === "number" && Number.isFinite(args.offset)
207
+ ? boundedInteger(args.offset, 0, 0, history.length)
208
+ : Math.max(0, history.length - limit);
209
+ var slice = history.slice(offset, offset + limit);
210
+ var end = offset + slice.length;
211
+ var out = [];
212
+ out.push("# " + (source.title || "Untitled session") + " — " + (source.vendor || "unknown") + "/" + source.localId);
213
+ out.push("Showing entries " + (slice.length ? offset + 1 : 0) + "-" + end + " of " + history.length + "\n");
214
+ for (var i = 0; i < slice.length; i++) {
215
+ var entry = slice[i];
216
+ if (!entry) continue;
217
+ var label;
218
+ var text = "";
219
+ if (entry.type === "user_message") {
220
+ label = "USER";
221
+ text = entry.text || "";
222
+ } else if (entry.type === "delta") {
223
+ label = "ASSISTANT";
224
+ text = entry.text || "";
225
+ } else if (entry.type === "tool_executing" || entry.type === "tool_result") {
226
+ label = "TOOL";
227
+ text = (entry.name || "") + (entry.input ? " " + JSON.stringify(entry.input).substring(0, 120) : "");
228
+ } else {
229
+ continue;
230
+ }
231
+ if (text.length > 800) text = text.substring(0, 800) + "...";
232
+ out.push("[" + label + "] " + text);
233
+ }
234
+ return out.join("\n");
235
+ }
236
+
237
+ function readHandoffSource(args, boundSession) {
238
+ if (!boundSession) return toolError("read_handoff_source requires a session-bound tool server");
239
+ if (!boundSession.handoff) return toolError("this session does not have a handoff source");
240
+ var resolved = resolveSourceChain(boundSession);
241
+ if (resolved.error) return toolError(resolved.error);
242
+ var source = findSource(resolved.chain, args.sourceSessionId);
243
+ if (!source) return toolError("sourceSessionId is not in this session's handoff chain");
244
+ return toolResult(formatHistory(source, args));
245
+ }
246
+
247
+ function getToolDefs(boundSession) {
248
+ if (ctx.isMate) return [];
249
+ if (boundSession && !boundSession.handoff) return [];
250
+ return sessionHandoffMcp.getToolDefs({
251
+ read: function (args) { return readHandoffSource(args, boundSession || null); },
252
+ });
253
+ }
254
+
255
+ function createMcpServer(adapter, boundSession) {
256
+ if (ctx.isMate || !adapter || typeof adapter.createToolServer !== "function") return null;
257
+ var tools = getToolDefs(boundSession || null);
258
+ if (tools.length === 0) return null;
259
+ return adapter.createToolServer({
260
+ name: "clay-handoff",
261
+ version: "1.0.0",
262
+ tools: tools,
263
+ });
264
+ }
265
+
266
+ function getSystemPrompt() {
267
+ return "";
268
+ }
269
+
270
+ return {
271
+ createMcpServer: createMcpServer,
272
+ getSystemPrompt: getSystemPrompt,
273
+ getToolDefs: getToolDefs,
274
+ handleMessage: handleMessage,
275
+ };
157
276
  }
158
277
 
159
278
  module.exports = {
package/lib/project.js CHANGED
@@ -620,6 +620,16 @@ function createProjectContext(opts) {
620
620
  }
621
621
  }
622
622
 
623
+ // Session-bound access to the source of a handoff (main projects only).
624
+ if (!isMate) {
625
+ try {
626
+ var sessionHandoffMcpConfig = _sessionHandoff.createMcpServer(adapter);
627
+ if (sessionHandoffMcpConfig) servers[sessionHandoffMcpConfig.name || "clay-handoff"] = sessionHandoffMcpConfig;
628
+ } catch (e) {
629
+ console.error("[project] Failed to create session handoff MCP server:", e.message);
630
+ }
631
+ }
632
+
623
633
  // Explicit agent signal for user-requested Markdown presentation.
624
634
  if (!isMate) {
625
635
  try {
@@ -780,7 +790,7 @@ function createProjectContext(opts) {
780
790
  // clay-email -> only when the user has an account or server SMTP
781
791
  //
782
792
  // forSession (optional): the session whose query these servers are mounted
783
- // into. Session, notes, and document tools must know their caller, so they
793
+ // into. Session, notes, handoff, and document tools must know their caller, so they
784
794
  // are re-instantiated bound to that session; static instances only serve
785
795
  // descriptor listing and fail closed on calls.
786
796
  function getLocalMcpServers(forSession) {
@@ -812,6 +822,15 @@ function createProjectContext(opts) {
812
822
  }
813
823
  continue;
814
824
  }
825
+ if (name === "clay-handoff" && forSession) {
826
+ try {
827
+ var boundHandoff = _sessionHandoff.createMcpServer(adapter, forSession);
828
+ if (boundHandoff) { filtered[name] = boundHandoff; hasAny = true; }
829
+ } catch (e) {
830
+ console.error("[project] Failed to bind session handoff MCP server:", e.message);
831
+ }
832
+ continue;
833
+ }
815
834
  if (name === "clay-documents" && forSession) {
816
835
  try {
817
836
  var boundDocuments = _sessionDocument.createMcpServer(adapter, forSession);
@@ -879,12 +898,14 @@ function createProjectContext(opts) {
879
898
  return composeSystemPrompts([
880
899
  _sessionPair.getSystemPrompt(session),
881
900
  _sessionNotes.getSystemPrompt(session),
901
+ _sessionHandoff.getSystemPrompt(session),
882
902
  _sessionDocument.getSystemPrompt(session),
883
903
  ]);
884
904
  },
885
905
  getSessionToolDefs: function (session) {
886
906
  return _sessionPair.getToolDefs(session)
887
907
  .concat(_sessionNotes.getToolDefs(session))
908
+ .concat(_sessionHandoff.getToolDefs(session))
888
909
  .concat(_sessionDocument.getToolDefs(session));
889
910
  },
890
911
  });
@@ -1489,6 +1510,15 @@ function createProjectContext(opts) {
1489
1510
  inputSchema: normalizeToolSchema(noteTools[nti].inputSchema),
1490
1511
  });
1491
1512
  }
1513
+ var handoffTools = _sessionHandoff.getToolDefs(boundSession);
1514
+ for (var hti = 0; hti < handoffTools.length; hti++) {
1515
+ tools.push({
1516
+ server: "clay-handoff",
1517
+ name: handoffTools[hti].name,
1518
+ description: handoffTools[hti].description || handoffTools[hti].name,
1519
+ inputSchema: normalizeToolSchema(handoffTools[hti].inputSchema),
1520
+ });
1521
+ }
1492
1522
  var documentTools = _sessionDocument.getToolDefs(boundSession);
1493
1523
  for (var dti = 0; dti < documentTools.length; dti++) {
1494
1524
  tools.push({
@@ -1509,7 +1539,7 @@ function createProjectContext(opts) {
1509
1539
  if (localMcp) {
1510
1540
  var inAppNames = Object.keys(localMcp);
1511
1541
  for (var i = 0; i < inAppNames.length; i++) {
1512
- if (inAppNames[i] === "clay-sessions" || inAppNames[i] === "clay-notes" || inAppNames[i] === "clay-documents") continue;
1542
+ if (inAppNames[i] === "clay-sessions" || inAppNames[i] === "clay-notes" || inAppNames[i] === "clay-handoff" || inAppNames[i] === "clay-documents") continue;
1513
1543
  extractServerTools(inAppNames[i], localMcp[inAppNames[i]]);
1514
1544
  }
1515
1545
  }
@@ -1544,6 +1574,14 @@ function createProjectContext(opts) {
1544
1574
  }
1545
1575
  }
1546
1576
  }
1577
+ if (boundSession && serverName === "clay-handoff") {
1578
+ var handoffTools = _sessionHandoff.getToolDefs(boundSession);
1579
+ for (var hti = 0; hti < handoffTools.length; hti++) {
1580
+ if (handoffTools[hti].name === toolName && typeof handoffTools[hti].handler === "function") {
1581
+ return Promise.resolve(handoffTools[hti].handler(args || {}));
1582
+ }
1583
+ }
1584
+ }
1547
1585
  if (boundSession && serverName === "clay-documents") {
1548
1586
  var documentTools = _sessionDocument.getToolDefs(boundSession);
1549
1587
  for (var dti = 0; dti < documentTools.length; dti++) {
@@ -1552,7 +1590,7 @@ function createProjectContext(opts) {
1552
1590
  }
1553
1591
  }
1554
1592
  }
1555
- if (serverName === "clay-sessions" || serverName === "clay-notes" || serverName === "clay-documents") {
1593
+ if (serverName === "clay-sessions" || serverName === "clay-notes" || serverName === "clay-handoff" || serverName === "clay-documents") {
1556
1594
  return Promise.reject(new Error("Session-bound tool requires a valid Clay session: " + serverName + "/" + toolName));
1557
1595
  }
1558
1596
  if (sessionOnly) return Promise.reject(new Error("Session tool not found: " + serverName + "/" + toolName));
@@ -97,6 +97,9 @@ function buildHandoffContext(options) {
97
97
  if (latestUser) parts.push("Current user request, verbatim:\n" + latestUser);
98
98
  parts.push("Repository state at handoff:\n" + repositoryState(options.cwd));
99
99
  parts.push("Recent conversation:\n" + transcriptText(turns));
100
+ if (options.sourceReadTool) {
101
+ parts.push("The snapshot above is partial. The read_handoff_source tool from the clay-handoff MCP server can read the full original session, including summarized tool calls and older turns. Call it when the snapshot leaves a question open.");
102
+ }
100
103
  parts.push("Continue from the unresolved work above. Preserve the user's decisions and constraints, inspect the actual files before making assumptions, and proceed without asking the user to repeat context.");
101
104
  return trimText(parts.join("\n\n"), MAX_CONTEXT_CHARS);
102
105
  }
@@ -0,0 +1,36 @@
1
+ // Session-bound access to the source of a Clay session handoff.
2
+
3
+ var buildShape = require("./session-spawn-mcp-server").buildShape;
4
+
5
+ var TOOL_DESCRIPTION =
6
+ "This session was continued from another agent's session via a context snapshot. " +
7
+ "That snapshot omitted tool calls and older turns. Read the original Clay session record directly, including user messages, assistant text, and summarized tool calls, regardless of which vendor produced it.";
8
+
9
+ function getToolDefs(handlers) {
10
+ return [
11
+ {
12
+ name: "read_handoff_source",
13
+ description: TOOL_DESCRIPTION,
14
+ inputSchema: buildShape({
15
+ offset: {
16
+ type: "number",
17
+ description: "Skip the first N history entries. Omit to return the last limit entries.",
18
+ },
19
+ limit: {
20
+ type: "number",
21
+ description: "Maximum history entries to return. Defaults to 30 and is capped at 100.",
22
+ },
23
+ sourceSessionId: {
24
+ type: "string",
25
+ description: "Source session in this session's handoff chain. Omit to read the immediate source.",
26
+ },
27
+ }),
28
+ handler: function (args) { return handlers.read(args || {}); },
29
+ },
30
+ ];
31
+ }
32
+
33
+ module.exports = {
34
+ TOOL_DESCRIPTION: TOOL_DESCRIPTION,
35
+ getToolDefs: getToolDefs,
36
+ };
@@ -7,6 +7,7 @@
7
7
  // codex "xhigh").
8
8
  var EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh", "max"];
9
9
 
10
+ // sessionBoundTools gates whether per-session MCP tool mounting reaches a vendor's process.
10
11
  var VENDOR_REGISTRY = {
11
12
  claude: {
12
13
  displayName: "Claude Code",
@@ -16,6 +17,7 @@ var VENDOR_REGISTRY = {
16
17
  sessionModes: ["gui", "tui"],
17
18
  effortLevels: ["low", "medium", "high", "xhigh", "max"],
18
19
  osUserIsolation: true,
20
+ sessionBoundTools: true,
19
21
  usageDashboard: {
20
22
  icon: "/claude-code-avatar.png",
21
23
  alt: "Claude Code",
@@ -32,6 +34,7 @@ var VENDOR_REGISTRY = {
32
34
  sessionModes: ["gui"],
33
35
  effortLevels: ["minimal", "low", "medium", "high", "xhigh"],
34
36
  osUserIsolation: true,
37
+ sessionBoundTools: false,
35
38
  usageDashboard: {
36
39
  icon: "/codex-avatar.png",
37
40
  alt: "Codex",
@@ -48,6 +51,7 @@ var VENDOR_REGISTRY = {
48
51
  sessionModes: ["gui"],
49
52
  effortLevels: ["low", "medium", "high"],
50
53
  osUserIsolation: false,
54
+ sessionBoundTools: false,
51
55
  usageDashboard: null,
52
56
  rateLimitTracking: false,
53
57
  },
@@ -59,6 +63,7 @@ var VENDOR_REGISTRY = {
59
63
  sessionModes: ["gui"],
60
64
  effortLevels: [],
61
65
  osUserIsolation: false,
66
+ sessionBoundTools: true,
62
67
  usageDashboard: null,
63
68
  rateLimitTracking: false,
64
69
  },
@@ -70,6 +75,7 @@ var VENDOR_REGISTRY = {
70
75
  sessionModes: ["gui"],
71
76
  effortLevels: [],
72
77
  osUserIsolation: false,
78
+ sessionBoundTools: true,
73
79
  usageDashboard: null,
74
80
  rateLimitTracking: false,
75
81
  },
@@ -81,6 +87,7 @@ var VENDOR_REGISTRY = {
81
87
  sessionModes: ["gui"],
82
88
  effortLevels: [],
83
89
  osUserIsolation: false,
90
+ sessionBoundTools: true,
84
91
  usageDashboard: null,
85
92
  rateLimitTracking: false,
86
93
  },
@@ -92,6 +99,7 @@ var VENDOR_REGISTRY = {
92
99
  sessionModes: ["gui"],
93
100
  effortLevels: [],
94
101
  osUserIsolation: false,
102
+ sessionBoundTools: true,
95
103
  usageDashboard: null,
96
104
  rateLimitTracking: false,
97
105
  },
@@ -103,6 +111,7 @@ var VENDOR_REGISTRY = {
103
111
  sessionModes: ["gui"],
104
112
  effortLevels: [],
105
113
  osUserIsolation: false,
114
+ sessionBoundTools: true,
106
115
  usageDashboard: null,
107
116
  rateLimitTracking: false,
108
117
  },
@@ -114,6 +123,7 @@ var VENDOR_REGISTRY = {
114
123
  sessionModes: ["gui"],
115
124
  effortLevels: [],
116
125
  osUserIsolation: false,
126
+ sessionBoundTools: true,
117
127
  usageDashboard: null,
118
128
  rateLimitTracking: false,
119
129
  },
@@ -125,6 +135,7 @@ var VENDOR_REGISTRY = {
125
135
  sessionModes: ["gui"],
126
136
  effortLevels: [],
127
137
  osUserIsolation: false,
138
+ sessionBoundTools: true,
128
139
  usageDashboard: null,
129
140
  rateLimitTracking: false,
130
141
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "3.4.0",
3
+ "version": "3.5.0-beta.1",
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",