clay-server 2.34.0-beta.3 → 2.34.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/ask-user-mcp-server.js +120 -0
- package/lib/daemon.js +97 -38
- package/lib/mates.js +2 -2
- package/lib/project-connection.js +15 -0
- package/lib/project-sessions.js +73 -4
- package/lib/project.js +75 -8
- package/lib/public/css/mates.css +26 -11
- package/lib/public/index.html +17 -19
- package/lib/public/modules/app-dm.js +0 -2
- package/lib/public/modules/app-messages.js +2 -0
- package/lib/public/modules/mate-sidebar.js +0 -9
- package/lib/public/modules/mate-wizard.js +15 -15
- package/lib/public/modules/tools.js +20 -2
- package/lib/sdk-bridge.js +23 -19
- package/lib/sdk-message-processor.js +14 -3
- package/lib/server.js +28 -72
- package/lib/sessions.js +67 -20
- package/lib/yoke/adapters/claude-worker.js +108 -0
- package/lib/yoke/adapters/claude.js +17 -3
- package/lib/yoke/adapters/codex.js +318 -54
- package/lib/yoke/index.js +40 -28
- package/lib/yoke/mcp-bridge-server.js +14 -6
- package/package.json +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Ask User MCP Server for Clay
|
|
2
|
+
// Provides a mate-only ask_user_questions tool that reuses the existing
|
|
3
|
+
// AskUserQuestion UI and ask_user_response flow.
|
|
4
|
+
|
|
5
|
+
var z;
|
|
6
|
+
try { z = require("zod"); } catch (e) { z = null; }
|
|
7
|
+
|
|
8
|
+
// Returns a Zod "shape" object (property -> zod field) matching what
|
|
9
|
+
// Claude SDK's `sdk.tool()` expects. Do NOT wrap in z.object() here —
|
|
10
|
+
// the SDK does that internally.
|
|
11
|
+
//
|
|
12
|
+
// The schema is deliberately tight: options are required (2-6), each with
|
|
13
|
+
// a label and a short description. This pushes Codex (which treats the
|
|
14
|
+
// schema as a loose hint) toward producing well-structured multiple-choice
|
|
15
|
+
// cards instead of a bare "Other..." text field.
|
|
16
|
+
function buildQuestionShape() {
|
|
17
|
+
if (!z) return {};
|
|
18
|
+
|
|
19
|
+
var optionSchema = z.object({
|
|
20
|
+
label: z.string().min(1).max(60)
|
|
21
|
+
.describe("Short button label, 1-6 words. Shown as the primary option text."),
|
|
22
|
+
description: z.string().min(1).max(160)
|
|
23
|
+
.describe("One-line clarifier shown under the label. Concrete example or scope."),
|
|
24
|
+
markdown: z.string().optional()
|
|
25
|
+
.describe("Optional longer markdown body shown on expand. Use sparingly."),
|
|
26
|
+
}).passthrough();
|
|
27
|
+
|
|
28
|
+
var questionSchema = z.object({
|
|
29
|
+
header: z.string().min(1).max(40)
|
|
30
|
+
.describe("Short ALL-CAPS-ish section header above the question (e.g. 'FIRST THINGS FIRST', 'SCOPE'). Gives the user context for this step."),
|
|
31
|
+
question: z.string().min(1)
|
|
32
|
+
.describe("The actual question in natural spoken tone. Be specific, not generic."),
|
|
33
|
+
multiSelect: z.boolean().optional()
|
|
34
|
+
.describe("Set true only when multiple answers genuinely make sense. Default false."),
|
|
35
|
+
options: z.array(optionSchema).min(2).max(6)
|
|
36
|
+
.describe("Concrete options. Preferred count is 4; acceptable range is 2-6. FOUR is the target. Do NOT default to 3 out of habit. If you can only think of 3, push yourself: add a scope variant, edge case, or 'combined' option as the 4th. Only drop below 4 when the answer space is genuinely narrower (e.g. yes/no/unsure). Never include an 'Other' option yourself (the UI renders one automatically)."),
|
|
37
|
+
}).passthrough();
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
questions: z.array(questionSchema).min(1).max(3)
|
|
41
|
+
.describe("One to three question objects to show together as a card. Prefer ONE focused question per call."),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
var TOOL_DESCRIPTION = [
|
|
46
|
+
"Ask the user a structured multiple-choice question. Renders as a card with a header, the question, 2-6 clickable option buttons, and an always-present 'Other...' free-text field.",
|
|
47
|
+
"",
|
|
48
|
+
"WHEN TO USE:",
|
|
49
|
+
"- Interviewing the user at mate setup, or whenever you need to narrow scope before acting.",
|
|
50
|
+
"- Any branching decision where 2-6 concrete choices cover most of the space.",
|
|
51
|
+
"",
|
|
52
|
+
"REQUIRED STRUCTURE (all fields matter, do not skip):",
|
|
53
|
+
"- header: short section label, like 'FIRST THINGS FIRST' or 'SCOPE'. Gives context.",
|
|
54
|
+
"- question: one specific question in natural tone. Avoid generic 'how can I help?'.",
|
|
55
|
+
"- options: ALWAYS provide FOUR options by default. Acceptable range is 4-6; only drop to 3 if the answer space is genuinely binary-plus-one (rare). Each option has a short label (1-6 words) AND a one-line description giving a concrete example or scope. The UI already renders an 'Other...' text field, so you never need to include an 'Other' option yourself.",
|
|
56
|
+
"- multiSelect: omit or false unless multiple answers clearly apply.",
|
|
57
|
+
"",
|
|
58
|
+
"ON OPTION COUNT (IMPORTANT):",
|
|
59
|
+
"- Default to 4 options. Not 3. Models tend to gravitate to 3 because it feels tidy; resist that.",
|
|
60
|
+
"- If you're about to produce 3 options, stop and think: is there a fourth axis you're missing? A scope variant? A 'both' or 'neither'? A more niche case? Add it.",
|
|
61
|
+
"- 3 is only acceptable when the fourth option would be truly degenerate (e.g. yes/no/unsure).",
|
|
62
|
+
"- 5 or 6 is fine when the space is wide; don't cram.",
|
|
63
|
+
"",
|
|
64
|
+
"GOOD EXAMPLE:",
|
|
65
|
+
' { header: "FIRST THINGS FIRST",',
|
|
66
|
+
' question: "\'Language\' is broad, what do you actually want help with?",',
|
|
67
|
+
' options: [',
|
|
68
|
+
' { label: "A new language from scratch", description: "Pick up a language you don\'t speak yet (e.g. Japanese, Spanish)" },',
|
|
69
|
+
' { label: "Sharpen English", description: "Level up your English, writing, speaking, nuance, executive communication" },',
|
|
70
|
+
' { label: "Sharpen Korean", description: "Polish your Korean, writing style, formal register, etc." },',
|
|
71
|
+
' { label: "Teach me how to teach", description: "Help you teach language to others, like Elyse or team members" }',
|
|
72
|
+
" ] }",
|
|
73
|
+
"",
|
|
74
|
+
"BAD EXAMPLES (do not do this):",
|
|
75
|
+
"- Asking a vague question with no options and relying on the Other field.",
|
|
76
|
+
"- Options with only labels and no descriptions.",
|
|
77
|
+
"- Defaulting to exactly 3 options out of habit. Aim for 4.",
|
|
78
|
+
"- More than one question per call unless they are tightly related.",
|
|
79
|
+
"- Single-option 'questions' (use a plain message instead).",
|
|
80
|
+
].join("\n");
|
|
81
|
+
|
|
82
|
+
function getToolDefs(onAsk) {
|
|
83
|
+
var tools = [];
|
|
84
|
+
|
|
85
|
+
tools.push({
|
|
86
|
+
name: "ask_user_questions",
|
|
87
|
+
description: TOOL_DESCRIPTION,
|
|
88
|
+
inputSchema: buildQuestionShape(),
|
|
89
|
+
handler: function (args) {
|
|
90
|
+
if (!args || !Array.isArray(args.questions) || args.questions.length === 0) {
|
|
91
|
+
return Promise.resolve({
|
|
92
|
+
content: [{ type: "text", text: "Error: questions must be a non-empty array." }],
|
|
93
|
+
isError: true,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Defensive structural check only: zod already enforces min 2.
|
|
97
|
+
// We do NOT reject on "only 3 options" — that's a soft preference
|
|
98
|
+
// expressed via the tool description, not a hard validation rule.
|
|
99
|
+
for (var i = 0; i < args.questions.length; i++) {
|
|
100
|
+
var q = args.questions[i];
|
|
101
|
+
if (!q || !Array.isArray(q.options) || q.options.length < 2) {
|
|
102
|
+
return Promise.resolve({
|
|
103
|
+
content: [{
|
|
104
|
+
type: "text",
|
|
105
|
+
text: "Error: question " + (i + 1) + " must include at least 2 options. "
|
|
106
|
+
+ "Provide concrete { label, description } choices (4 preferred). "
|
|
107
|
+
+ "The UI already shows an 'Other...' free-text field, so never add an 'Other' option yourself.",
|
|
108
|
+
}],
|
|
109
|
+
isError: true,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return onAsk(args);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return tools;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { getToolDefs: getToolDefs };
|
package/lib/daemon.js
CHANGED
|
@@ -904,6 +904,47 @@ var relay = createServer({
|
|
|
904
904
|
},
|
|
905
905
|
});
|
|
906
906
|
|
|
907
|
+
var IDLE_MS = Number(process.env.CLAY_CODEX_IDLE_MS) || 5 * 60 * 1000;
|
|
908
|
+
var REAP_MS = Number(process.env.CLAY_CODEX_REAPER_MS) || 60 * 1000;
|
|
909
|
+
var reaperHandle = setInterval(function () {
|
|
910
|
+
if (!relay || typeof relay.forEachProject !== "function") return;
|
|
911
|
+
relay.forEachProject(function (ctx) {
|
|
912
|
+
try {
|
|
913
|
+
if (ctx && ctx.adapters && ctx.adapters.codex && typeof ctx.adapters.codex.shutdownIfIdle === "function") {
|
|
914
|
+
var result = ctx.adapters.codex.shutdownIfIdle(IDLE_MS);
|
|
915
|
+
if (result && typeof result.catch === "function") {
|
|
916
|
+
result.catch(function (e) {
|
|
917
|
+
console.error("[daemon] Codex idle reclaim failed:", e && e.message ? e.message : e);
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
} catch (e) {}
|
|
922
|
+
});
|
|
923
|
+
}, REAP_MS);
|
|
924
|
+
if (reaperHandle && typeof reaperHandle.unref === "function") {
|
|
925
|
+
reaperHandle.unref();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function stopCodexReaper() {
|
|
929
|
+
if (reaperHandle) {
|
|
930
|
+
clearInterval(reaperHandle);
|
|
931
|
+
reaperHandle = null;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function shutdownProjects() {
|
|
936
|
+
stopCodexReaper();
|
|
937
|
+
try {
|
|
938
|
+
var result = relay.destroyAll();
|
|
939
|
+
if (result && typeof result.then === "function") {
|
|
940
|
+
return result;
|
|
941
|
+
}
|
|
942
|
+
return Promise.resolve(true);
|
|
943
|
+
} catch (e) {
|
|
944
|
+
return Promise.reject(e);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
907
948
|
// Worktree tracking extracted to daemon-projects.js
|
|
908
949
|
|
|
909
950
|
// --- Register projects ---
|
|
@@ -1305,41 +1346,48 @@ function spawnAndRestart() {
|
|
|
1305
1346
|
try {
|
|
1306
1347
|
updateHandoff = true;
|
|
1307
1348
|
ipc.close();
|
|
1308
|
-
|
|
1309
|
-
|
|
1349
|
+
shutdownProjects().then(function () {
|
|
1350
|
+
if (relay.onboardingServer) relay.onboardingServer.close();
|
|
1310
1351
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1352
|
+
// Close the server first so the port is released before spawning the new daemon
|
|
1353
|
+
relay.server.close(function () {
|
|
1354
|
+
try {
|
|
1355
|
+
var { spawn: spawnRestart } = require("child_process");
|
|
1356
|
+
var { logPath: restartLogPath, configPath: restartConfigPath } = require("./config");
|
|
1357
|
+
var daemonScript = path.join(__dirname, "daemon.js");
|
|
1358
|
+
var logFd = fs.openSync(restartLogPath(), "a");
|
|
1359
|
+
var child = spawnRestart(process.execPath, [daemonScript], {
|
|
1360
|
+
detached: true,
|
|
1361
|
+
windowsHide: true,
|
|
1362
|
+
stdio: ["ignore", logFd, logFd],
|
|
1363
|
+
env: Object.assign({}, process.env, {
|
|
1364
|
+
CLAY_CONFIG: restartConfigPath(),
|
|
1365
|
+
}),
|
|
1366
|
+
});
|
|
1367
|
+
child.unref();
|
|
1368
|
+
fs.closeSync(logFd);
|
|
1369
|
+
config.pid = child.pid;
|
|
1370
|
+
saveConfig(config);
|
|
1371
|
+
console.log("[daemon] Spawned new daemon (PID " + child.pid + "), exiting.");
|
|
1372
|
+
process.exit(120);
|
|
1373
|
+
} catch (e) {
|
|
1374
|
+
console.error("[daemon] Restart failed:", e.message);
|
|
1375
|
+
process.exit(1);
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
}).catch(function (e) {
|
|
1379
|
+
console.error("[daemon] Restart shutdown failed:", e && e.message ? e.message : e);
|
|
1380
|
+
if (relay.onboardingServer) relay.onboardingServer.close();
|
|
1381
|
+
relay.server.close(function () {
|
|
1334
1382
|
process.exit(1);
|
|
1335
|
-
}
|
|
1383
|
+
});
|
|
1336
1384
|
});
|
|
1337
1385
|
|
|
1338
|
-
// Force exit after
|
|
1386
|
+
// Force exit after 10 seconds if server.close hangs
|
|
1339
1387
|
setTimeout(function () {
|
|
1340
1388
|
console.error("[daemon] Forced exit after timeout during restart");
|
|
1341
1389
|
process.exit(1);
|
|
1342
|
-
},
|
|
1390
|
+
}, 10000);
|
|
1343
1391
|
} catch (e) {
|
|
1344
1392
|
console.error("[daemon] Restart failed:", e.message);
|
|
1345
1393
|
relay.broadcastAll({ type: "toast", level: "error", message: "Restart failed: " + e.message });
|
|
@@ -1349,9 +1397,12 @@ function spawnAndRestart() {
|
|
|
1349
1397
|
|
|
1350
1398
|
// --- Graceful shutdown ---
|
|
1351
1399
|
var updateHandoff = false; // true when shutting down for update (new daemon already spawned)
|
|
1400
|
+
var shutdownStarted = false;
|
|
1352
1401
|
|
|
1353
1402
|
function gracefulShutdown() {
|
|
1354
1403
|
try { console.log("[daemon] Shutting down..."); } catch (e) {}
|
|
1404
|
+
if (shutdownStarted) return;
|
|
1405
|
+
shutdownStarted = true;
|
|
1355
1406
|
var exitCode = updateHandoff ? 120 : 0; // 120 = update handoff, don't auto-restart
|
|
1356
1407
|
|
|
1357
1408
|
if (caffeinateProc) {
|
|
@@ -1371,22 +1422,30 @@ function gracefulShutdown() {
|
|
|
1371
1422
|
} catch (e) {}
|
|
1372
1423
|
}
|
|
1373
1424
|
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
}
|
|
1425
|
+
shutdownProjects().then(function () {
|
|
1426
|
+
if (relay.onboardingServer) {
|
|
1427
|
+
relay.onboardingServer.close();
|
|
1428
|
+
}
|
|
1379
1429
|
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1430
|
+
relay.server.close(function () {
|
|
1431
|
+
try { console.log("[daemon] Server closed"); } catch (e) {}
|
|
1432
|
+
process.exit(exitCode);
|
|
1433
|
+
});
|
|
1434
|
+
}).catch(function (e) {
|
|
1435
|
+
console.error("[daemon] Shutdown cleanup failed:", e && e.message ? e.message : e);
|
|
1436
|
+
if (relay.onboardingServer) {
|
|
1437
|
+
relay.onboardingServer.close();
|
|
1438
|
+
}
|
|
1439
|
+
relay.server.close(function () {
|
|
1440
|
+
process.exit(exitCode);
|
|
1441
|
+
});
|
|
1383
1442
|
});
|
|
1384
1443
|
|
|
1385
|
-
// Force exit after
|
|
1444
|
+
// Force exit after 10 seconds
|
|
1386
1445
|
setTimeout(function () {
|
|
1387
1446
|
try { console.error("[daemon] Forced exit after timeout"); } catch (e) {}
|
|
1388
1447
|
process.exit(1);
|
|
1389
|
-
},
|
|
1448
|
+
}, 10000);
|
|
1390
1449
|
}
|
|
1391
1450
|
|
|
1392
1451
|
process.on("SIGTERM", gracefulShutdown);
|
package/lib/mates.js
CHANGED
|
@@ -131,6 +131,7 @@ function createMate(ctx, seedData) {
|
|
|
131
131
|
createdBy: userId,
|
|
132
132
|
createdAt: Date.now(),
|
|
133
133
|
seedData: seedData || {},
|
|
134
|
+
vendor: (seedData && seedData.vendor) || "claude",
|
|
134
135
|
profile: {
|
|
135
136
|
displayName: null,
|
|
136
137
|
avatarColor: colors[colorIdx],
|
|
@@ -158,7 +159,7 @@ function createMate(ctx, seedData) {
|
|
|
158
159
|
yaml += "createdAt: " + mate.createdAt + "\n";
|
|
159
160
|
yaml += "relationship: " + (seedData.relationship || "assistant") + "\n";
|
|
160
161
|
yaml += "activities: " + JSON.stringify(seedData.activity || []) + "\n";
|
|
161
|
-
yaml += "
|
|
162
|
+
yaml += "vendor: " + (seedData.vendor || "claude") + "\n";
|
|
162
163
|
fs.writeFileSync(path.join(mateDir, "mate.yaml"), yaml);
|
|
163
164
|
|
|
164
165
|
// Write initial CLAUDE.md (will be replaced by interview)
|
|
@@ -172,7 +173,6 @@ function createMate(ctx, seedData) {
|
|
|
172
173
|
if (seedData.communicationStyle && seedData.communicationStyle.length > 0) {
|
|
173
174
|
claudeMd += "- Communication: " + seedData.communicationStyle.join(", ") + "\n";
|
|
174
175
|
}
|
|
175
|
-
claudeMd += "- Autonomy: " + (seedData.autonomy || "always_ask") + "\n";
|
|
176
176
|
var initialIdentity = claudeMd.trimEnd();
|
|
177
177
|
claudeMd += TEAM_SECTION;
|
|
178
178
|
claudeMd += SESSION_MEMORY_SECTION;
|
|
@@ -52,12 +52,27 @@ function attachConnection(ctx) {
|
|
|
52
52
|
var getLatestVersion = ctx.getLatestVersion;
|
|
53
53
|
var getTitle = ctx.getTitle;
|
|
54
54
|
var getProject = ctx.getProject;
|
|
55
|
+
var warmup = ctx.warmup;
|
|
56
|
+
|
|
57
|
+
// Adapters are initialized lazily: the first websocket connection into
|
|
58
|
+
// this project triggers warmup. Without this guard we would either keep
|
|
59
|
+
// the old eager behavior (30+ Codex processes at daemon start) or run
|
|
60
|
+
// warmup once per reconnect.
|
|
61
|
+
var _warmedUp = false;
|
|
55
62
|
|
|
56
63
|
function handleConnection(ws, wsUser, handleMessage, handleDisconnection) {
|
|
57
64
|
ws._clayUser = wsUser || null;
|
|
58
65
|
clients.add(ws);
|
|
59
66
|
broadcastClientCount();
|
|
60
67
|
|
|
68
|
+
if (!_warmedUp) {
|
|
69
|
+
_warmedUp = true;
|
|
70
|
+
if (typeof warmup === "function") {
|
|
71
|
+
try { warmup(); }
|
|
72
|
+
catch (e) { console.error("[project-connection] warmup failed for " + slug + ":", e && e.message ? e.message : e); }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
61
76
|
var loopState = _loop.loopState;
|
|
62
77
|
var loopRegistry = _loop.loopRegistry;
|
|
63
78
|
|
package/lib/project-sessions.js
CHANGED
|
@@ -3,6 +3,33 @@ var path = require("path");
|
|
|
3
3
|
var { execFileSync } = require("child_process");
|
|
4
4
|
var { CODEX_DEFAULTS, getCodexConfig } = require("./codex-defaults");
|
|
5
5
|
|
|
6
|
+
// Format a user's answer to an ask_user_questions card as a plain user
|
|
7
|
+
// message so the MCP path can feed it back to the agent on the next turn.
|
|
8
|
+
// The agent sees: its own question text, then the selected answer(s).
|
|
9
|
+
function formatAskUserAnswerAsMessage(input, answers) {
|
|
10
|
+
var questions = (input && Array.isArray(input.questions)) ? input.questions : [];
|
|
11
|
+
if (questions.length === 0) {
|
|
12
|
+
// Shouldn't happen, but be defensive.
|
|
13
|
+
try { return "(answered with: " + JSON.stringify(answers || {}) + ")"; }
|
|
14
|
+
catch (e) { return "(answered)"; }
|
|
15
|
+
}
|
|
16
|
+
var lines = [];
|
|
17
|
+
for (var i = 0; i < questions.length; i++) {
|
|
18
|
+
var q = questions[i];
|
|
19
|
+
var qText = (q && q.question) ? q.question : ("Question " + (i + 1));
|
|
20
|
+
var ans = (answers && answers[i] != null) ? String(answers[i]) : "";
|
|
21
|
+
if (!ans) continue;
|
|
22
|
+
if (questions.length === 1) {
|
|
23
|
+
// Single question: keep it tight.
|
|
24
|
+
lines.push(ans);
|
|
25
|
+
} else {
|
|
26
|
+
lines.push("- " + qText + " → " + ans);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (lines.length === 0) return "(no answer provided)";
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
6
33
|
/**
|
|
7
34
|
* Attach session management, config, project management, and mid-section
|
|
8
35
|
* message handlers to a project context.
|
|
@@ -243,6 +270,17 @@ function attachSessions(ctx) {
|
|
|
243
270
|
|
|
244
271
|
if (msg.type === "switch_session") {
|
|
245
272
|
if (msg.id && sm.sessions.has(msg.id)) {
|
|
273
|
+
// If the target session's vendor doesn't own the currently cached
|
|
274
|
+
// model, clear sm.currentModel so the UI and next query don't leak
|
|
275
|
+
// the previous session's vendor-specific model into this one.
|
|
276
|
+
var switchTargetSess = sm.sessions.get(msg.id);
|
|
277
|
+
if (switchTargetSess && sm.currentModel) {
|
|
278
|
+
var targetVendor = switchTargetSess.vendor || sm.defaultVendor || null;
|
|
279
|
+
var tvModels = (targetVendor && sm.modelsByVendor && sm.modelsByVendor[targetVendor]) || [];
|
|
280
|
+
if (tvModels.length > 0 && tvModels.indexOf(sm.currentModel) === -1) {
|
|
281
|
+
sm.currentModel = "";
|
|
282
|
+
}
|
|
283
|
+
}
|
|
246
284
|
// Check access in multi-user mode
|
|
247
285
|
if (usersModule.isMultiUser() && ws._clayUser) {
|
|
248
286
|
var switchTarget = sm.sessions.get(msg.id);
|
|
@@ -747,10 +785,41 @@ function attachSessions(ctx) {
|
|
|
747
785
|
if (!pending) return true;
|
|
748
786
|
delete session.pendingAskUser[toolId];
|
|
749
787
|
sm.sendAndRecord(session, { type: "ask_user_answered", toolId: toolId, answers: answers });
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
788
|
+
|
|
789
|
+
if (pending.mode === "mcp") {
|
|
790
|
+
// Stateless MCP path: the tool already returned. Inject the user's
|
|
791
|
+
// answer as a new user message so the conversation continues
|
|
792
|
+
// naturally on the next turn. This matches how the mate would see
|
|
793
|
+
// any other user input.
|
|
794
|
+
var answerText = formatAskUserAnswerAsMessage(pending.input, answers);
|
|
795
|
+
var userMsg = { type: "user_message", text: answerText };
|
|
796
|
+
session.history.push(userMsg);
|
|
797
|
+
sm.appendToSessionFile(session, userMsg);
|
|
798
|
+
sendToSession(session.localId, userMsg);
|
|
799
|
+
|
|
800
|
+
if (!session.isProcessing) {
|
|
801
|
+
session.isProcessing = true;
|
|
802
|
+
onProcessingChanged();
|
|
803
|
+
session.sentToolResults = {};
|
|
804
|
+
sendToSession(session.localId, { type: "status", status: "processing" });
|
|
805
|
+
if (!session.queryInstance && !session.worker) {
|
|
806
|
+
sdk.startQuery(session, answerText, undefined, getLinuxUserForSession(session));
|
|
807
|
+
} else {
|
|
808
|
+
sdk.pushMessage(session, answerText);
|
|
809
|
+
}
|
|
810
|
+
} else {
|
|
811
|
+
// Turn is still running; queue for the next turn.
|
|
812
|
+
sdk.pushMessage(session, answerText);
|
|
813
|
+
}
|
|
814
|
+
} else {
|
|
815
|
+
// Claude native AskUserQuestion path (canUseTool). The SDK is
|
|
816
|
+
// synchronously blocked on the permission callback, so we must
|
|
817
|
+
// resolve it with the standard permission shape.
|
|
818
|
+
pending.resolve({
|
|
819
|
+
behavior: "allow",
|
|
820
|
+
updatedInput: Object.assign({}, pending.input, { answers: answers }),
|
|
821
|
+
});
|
|
822
|
+
}
|
|
754
823
|
return true;
|
|
755
824
|
}
|
|
756
825
|
|
package/lib/project.js
CHANGED
|
@@ -162,7 +162,7 @@ function createProjectContext(opts) {
|
|
|
162
162
|
var latestVersion = null;
|
|
163
163
|
|
|
164
164
|
// --- YOKE adapters (multi-vendor, lazy init) ---
|
|
165
|
-
var _yokeState = yoke.createAdapters({ cwd: cwd });
|
|
165
|
+
var _yokeState = yoke.createAdapters({ cwd: cwd, slug: slug });
|
|
166
166
|
var adapters = _yokeState.adapters;
|
|
167
167
|
var defaultVendor = adapters.claude ? "claude" : Object.keys(adapters)[0] || "claude";
|
|
168
168
|
var adapter = adapters[defaultVendor] || null;
|
|
@@ -496,6 +496,63 @@ function createProjectContext(opts) {
|
|
|
496
496
|
console.error("[project] Failed to create debate MCP server:", e.message);
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
+
// Ask-user MCP server (mates only)
|
|
500
|
+
if (isMate) {
|
|
501
|
+
try {
|
|
502
|
+
var askUserMcp = require("./ask-user-mcp-server");
|
|
503
|
+
var askUserToolDefs = askUserMcp.getToolDefs(function onAsk(input) {
|
|
504
|
+
// Stateless: the tool's job is to *post* the question card.
|
|
505
|
+
// We do NOT hold a promise open waiting for the user. When the
|
|
506
|
+
// user answers, the answer is injected as a fresh user message
|
|
507
|
+
// on the next turn (see project-sessions.js ask_user_response).
|
|
508
|
+
// This avoids HTTP long-poll timeouts on the MCP bridge and
|
|
509
|
+
// matches the natural multi-turn agent loop.
|
|
510
|
+
var session = sm.getActiveSession();
|
|
511
|
+
if (!session) {
|
|
512
|
+
// No active session means we have no way to show a card or
|
|
513
|
+
// route the answer. Fail closed rather than pretend success.
|
|
514
|
+
return Promise.resolve({
|
|
515
|
+
content: [{ type: "text", text: "Error: no active session in " + slug + "; cannot display question card." }],
|
|
516
|
+
isError: true,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (session.loop && session.loop.active && session.loop.role !== "crafting") {
|
|
520
|
+
return Promise.resolve({
|
|
521
|
+
content: [{ type: "text", text: "Error: Autonomous mode. Make your own decision." }],
|
|
522
|
+
isError: true,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
var toolId = "ask_" + Date.now() + "_" + crypto.randomUUID().slice(0, 8);
|
|
527
|
+
// Track for UI card lifecycle + answer routing. No resolve function.
|
|
528
|
+
session.pendingAskUser[toolId] = {
|
|
529
|
+
input: input,
|
|
530
|
+
mode: "mcp",
|
|
531
|
+
sessionId: session.localId,
|
|
532
|
+
postedAt: Date.now(),
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
sm.sendAndRecord(session, {
|
|
536
|
+
type: "tool_executing",
|
|
537
|
+
id: toolId,
|
|
538
|
+
name: "AskUserQuestion",
|
|
539
|
+
input: input,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
return Promise.resolve({
|
|
543
|
+
content: [{
|
|
544
|
+
type: "text",
|
|
545
|
+
text: "The question card has been posted to the user. End this turn now without further commentary; the user's answer will arrive as the next user message.",
|
|
546
|
+
}],
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
var askUserMcpConfig = adapter.createToolServer({ name: "clay-ask-user", version: "1.0.0", tools: askUserToolDefs });
|
|
550
|
+
if (askUserMcpConfig) servers[askUserMcpConfig.name || "clay-ask-user"] = askUserMcpConfig;
|
|
551
|
+
} catch (e) {
|
|
552
|
+
console.error("[project] Failed to create ask-user MCP server:", e.message);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
499
556
|
// Browser MCP server (main project only, not mates)
|
|
500
557
|
if (!isMate) {
|
|
501
558
|
try {
|
|
@@ -1318,6 +1375,13 @@ function createProjectContext(opts) {
|
|
|
1318
1375
|
getLatestVersion: function () { return latestVersion; },
|
|
1319
1376
|
getTitle: function () { return title; },
|
|
1320
1377
|
getProject: function () { return project; },
|
|
1378
|
+
// Exposed so the first websocket connection can lazily warm up the
|
|
1379
|
+
// adapters for this project (see project-connection handleConnection).
|
|
1380
|
+
warmup: function () {
|
|
1381
|
+
sdk.warmup();
|
|
1382
|
+
sdk.startIdleReaper();
|
|
1383
|
+
sm.migrateSessionTitles(adapter, cwd);
|
|
1384
|
+
},
|
|
1321
1385
|
});
|
|
1322
1386
|
|
|
1323
1387
|
// --- Destroy ---
|
|
@@ -1376,6 +1440,15 @@ function createProjectContext(opts) {
|
|
|
1376
1440
|
var tmpDir = path.join(os.tmpdir(), "clay-" + cwdHash);
|
|
1377
1441
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1378
1442
|
} catch (e) {}
|
|
1443
|
+
|
|
1444
|
+
var codexShutdown = Promise.resolve(true);
|
|
1445
|
+
if (adapters && adapters.codex && typeof adapters.codex.shutdown === "function") {
|
|
1446
|
+
codexShutdown = adapters.codex.shutdown().catch(function(err) {
|
|
1447
|
+
console.error("[project] Codex shutdown failed for " + slug + ":", err && err.message ? err.message : err);
|
|
1448
|
+
return false;
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
return codexShutdown;
|
|
1379
1452
|
}
|
|
1380
1453
|
|
|
1381
1454
|
// --- Status info ---
|
|
@@ -1523,15 +1596,9 @@ function createProjectContext(opts) {
|
|
|
1523
1596
|
broadcastClientCount();
|
|
1524
1597
|
broadcastPresence();
|
|
1525
1598
|
},
|
|
1526
|
-
warmup: function () {
|
|
1527
|
-
sdk.warmup();
|
|
1528
|
-
sdk.startIdleReaper();
|
|
1529
|
-
// Migrate existing relay session titles to SDK format (one-time, async)
|
|
1530
|
-
sm.migrateSessionTitles(adapter, cwd);
|
|
1531
|
-
},
|
|
1532
1599
|
destroy: function () {
|
|
1533
1600
|
sdk.stopIdleReaper();
|
|
1534
|
-
destroy();
|
|
1601
|
+
return destroy();
|
|
1535
1602
|
},
|
|
1536
1603
|
};
|
|
1537
1604
|
}
|
package/lib/public/css/mates.css
CHANGED
|
@@ -410,18 +410,18 @@
|
|
|
410
410
|
line-height: 1.4;
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
-
/* Step 4:
|
|
414
|
-
.mate-
|
|
413
|
+
/* Step 4: Vendor options */
|
|
414
|
+
.mate-vendor-option-list {
|
|
415
415
|
display: flex;
|
|
416
416
|
flex-direction: column;
|
|
417
|
-
gap:
|
|
417
|
+
gap: 10px;
|
|
418
418
|
}
|
|
419
|
-
.mate-
|
|
419
|
+
.mate-vendor-option-btn {
|
|
420
420
|
display: flex;
|
|
421
|
-
flex-direction:
|
|
421
|
+
flex-direction: row;
|
|
422
422
|
align-items: flex-start;
|
|
423
|
-
gap:
|
|
424
|
-
padding:
|
|
423
|
+
gap: 14px;
|
|
424
|
+
padding: 14px 16px;
|
|
425
425
|
background: var(--input-bg, #1a1a2e);
|
|
426
426
|
border: 2px solid var(--border, #333);
|
|
427
427
|
border-radius: 10px;
|
|
@@ -430,21 +430,36 @@
|
|
|
430
430
|
text-align: left;
|
|
431
431
|
color: var(--text, #fff);
|
|
432
432
|
font-family: inherit;
|
|
433
|
+
width: 100%;
|
|
433
434
|
}
|
|
434
|
-
.mate-
|
|
435
|
+
.mate-vendor-option-btn:hover {
|
|
435
436
|
border-color: var(--text-dimmer, #6272a4);
|
|
436
437
|
}
|
|
437
|
-
.mate-
|
|
438
|
+
.mate-vendor-option-btn.active {
|
|
438
439
|
border-color: var(--accent, #6c5ce7);
|
|
439
440
|
background: var(--accent-bg, rgba(108,92,231,0.12));
|
|
440
441
|
}
|
|
441
|
-
.mate-
|
|
442
|
+
.mate-vendor-option-icon {
|
|
443
|
+
width: 36px;
|
|
444
|
+
height: 36px;
|
|
445
|
+
border-radius: 8px;
|
|
446
|
+
flex-shrink: 0;
|
|
447
|
+
object-fit: cover;
|
|
448
|
+
}
|
|
449
|
+
.mate-vendor-option-text {
|
|
450
|
+
display: flex;
|
|
451
|
+
flex-direction: column;
|
|
452
|
+
gap: 4px;
|
|
453
|
+
min-width: 0;
|
|
454
|
+
}
|
|
455
|
+
.mate-vendor-option-title {
|
|
442
456
|
font-weight: 600;
|
|
443
457
|
font-size: 14px;
|
|
444
458
|
}
|
|
445
|
-
.mate-
|
|
459
|
+
.mate-vendor-option-desc {
|
|
446
460
|
font-size: 12px;
|
|
447
461
|
color: var(--text-muted, #9ea8c7);
|
|
462
|
+
line-height: 1.4;
|
|
448
463
|
}
|
|
449
464
|
|
|
450
465
|
/* Footer */
|