clay-server 2.33.1 → 2.34.0-beta.10

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 (47) hide show
  1. package/lib/ask-user-mcp-server.js +120 -0
  2. package/lib/config.js +9 -13
  3. package/lib/daemon.js +116 -55
  4. package/lib/mate-datastore.js +359 -0
  5. package/lib/mates.js +2 -2
  6. package/lib/os-users.js +70 -37
  7. package/lib/project-connection.js +16 -9
  8. package/lib/project-http.js +3 -4
  9. package/lib/project-image.js +3 -2
  10. package/lib/project-mate-datastore.js +232 -0
  11. package/lib/project-sessions.js +110 -7
  12. package/lib/project-user-message.js +4 -3
  13. package/lib/project.js +126 -10
  14. package/lib/public/app.js +2 -0
  15. package/lib/public/css/mates.css +228 -11
  16. package/lib/public/css/messages.css +23 -0
  17. package/lib/public/css/mobile-nav.css +0 -14
  18. package/lib/public/css/notifications-center.css +80 -0
  19. package/lib/public/css/sidebar.css +326 -101
  20. package/lib/public/index.html +24 -29
  21. package/lib/public/modules/app-dm.js +0 -2
  22. package/lib/public/modules/app-messages.js +23 -0
  23. package/lib/public/modules/app-rendering.js +0 -2
  24. package/lib/public/modules/diff.js +21 -7
  25. package/lib/public/modules/mate-datastore-ui.js +280 -0
  26. package/lib/public/modules/mate-sidebar.js +3 -9
  27. package/lib/public/modules/mate-wizard.js +15 -15
  28. package/lib/public/modules/sidebar-mobile.js +10 -20
  29. package/lib/public/modules/sidebar-sessions.js +490 -113
  30. package/lib/public/modules/sidebar.js +8 -6
  31. package/lib/public/modules/tools.js +115 -18
  32. package/lib/public/sw.js +1 -1
  33. package/lib/sdk-bridge.js +56 -41
  34. package/lib/sdk-message-processor.js +21 -4
  35. package/lib/server.js +28 -72
  36. package/lib/sessions.js +157 -20
  37. package/lib/updater.js +2 -2
  38. package/lib/users.js +2 -2
  39. package/lib/ws-schema.js +16 -0
  40. package/lib/yoke/adapters/claude-worker.js +114 -2
  41. package/lib/yoke/adapters/claude.js +56 -5
  42. package/lib/yoke/adapters/codex.js +350 -58
  43. package/lib/yoke/index.js +93 -48
  44. package/lib/yoke/instructions.js +0 -1
  45. package/lib/yoke/mcp-bridge-server.js +14 -6
  46. package/package.json +1 -2
  47. package/lib/yoke/adapters/gemini.js +0 -709
@@ -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/config.js CHANGED
@@ -2,31 +2,27 @@ var fs = require("fs");
2
2
  var path = require("path");
3
3
  var os = require("os");
4
4
  var net = require("net");
5
+ var execFileSync = require("child_process").execFileSync;
6
+
7
+ function isSafeSystemUserName(name) {
8
+ return typeof name === "string" && /^[a-z_][a-z0-9_-]*[$]?$/.test(name);
9
+ }
5
10
 
6
11
  // When running under sudo, resolve the real user's home directory
7
12
  // so that ~/.clay/ points to the original user's data, not /root/.clay/
8
13
  function getRealHome() {
9
14
  var sudoUser = process.env.SUDO_USER;
10
- if (sudoUser && sudoUser !== "root") {
15
+ if (sudoUser && sudoUser !== "root" && isSafeSystemUserName(sudoUser)) {
11
16
  // 1. Try getent passwd (works on most Linux, may fail with some NSS configs)
12
17
  try {
13
- var entry = require("child_process")
14
- .execSync("getent passwd " + sudoUser, { encoding: "utf8", timeout: 3000 })
15
- .trim();
18
+ var entry = execFileSync("getent", ["passwd", sudoUser], { encoding: "utf8", timeout: 3000 }).trim();
16
19
  var home = entry.split(":")[5];
17
20
  if (home && fs.existsSync(home)) return home;
18
21
  } catch (e) {}
19
- // 2. Try shell expansion of ~USER
20
- try {
21
- var home = require("child_process")
22
- .execSync("eval echo ~" + sudoUser, { encoding: "utf8", timeout: 3000 })
23
- .trim();
24
- if (home && home !== "~" + sudoUser && fs.existsSync(home)) return home;
25
- } catch (e2) {}
26
- // 3. Direct path fallback (GCE, cloud VMs)
22
+ // 2. Direct path fallback (GCE, cloud VMs)
27
23
  var directHome = "/home/" + sudoUser;
28
24
  if (fs.existsSync(directHome)) return directHome;
29
- // 4. SUDO_USER's original HOME (some sudo configs preserve it)
25
+ // 3. SUDO_USER's original HOME (some sudo configs preserve it)
30
26
  if (process.env.SUDO_HOME && fs.existsSync(process.env.SUDO_HOME)) return process.env.SUDO_HOME;
31
27
  }
32
28
  return os.homedir();
package/lib/daemon.js CHANGED
@@ -53,11 +53,11 @@ console.log("[daemon] UID: " + (typeof process.getuid === "function" ? process.g
53
53
 
54
54
  // --- OS users mode: check required system dependencies ---
55
55
  if (config.osUsers) {
56
- var { execSync: checkExec } = require("child_process");
56
+ var checkExec = require("child_process").execFileSync;
57
57
  var missing = [];
58
- try { checkExec("which setfacl", { stdio: "ignore" }); } catch (e) { missing.push("acl (setfacl)"); }
59
- try { checkExec("which git", { stdio: "ignore" }); } catch (e) { missing.push("git"); }
60
- try { checkExec("which useradd", { stdio: "ignore" }); } catch (e) { missing.push("useradd"); }
58
+ try { checkExec("which", ["setfacl"], { stdio: "ignore" }); } catch (e) { missing.push("acl (setfacl)"); }
59
+ try { checkExec("which", ["git"], { stdio: "ignore" }); } catch (e) { missing.push("git"); }
60
+ try { checkExec("which", ["useradd"], { stdio: "ignore" }); } catch (e) { missing.push("useradd"); }
61
61
  if (missing.length > 0) {
62
62
  console.error("[daemon] OS users mode requires missing system packages: " + missing.join(", "));
63
63
  console.error("[daemon] Install with: sudo apt install " + missing.map(function (m) { return m.split(" ")[0]; }).join(" "));
@@ -179,7 +179,7 @@ var relay = createServer({
179
179
  onCreateProject: function (projectName, wsUser) {
180
180
  console.log("[daemon] onCreateProject wsUser:", JSON.stringify(wsUser ? { id: wsUser.id, role: wsUser.role, username: wsUser.username, linuxUser: wsUser.linuxUser } : null));
181
181
  var os = require("os");
182
- var { execSync } = require("child_process");
182
+ var execFileSync = require("child_process").execFileSync;
183
183
  var baseDir;
184
184
  if (config.osUsers) {
185
185
  baseDir = "/var/clay/projects";
@@ -199,21 +199,23 @@ var relay = createServer({
199
199
  if (linuxUser) {
200
200
  var uidGid = null;
201
201
  try {
202
- var passwdLine = execSync("id -u " + linuxUser + " && id -g " + linuxUser, { encoding: "utf8" }).trim().split("\n");
203
- uidGid = { uid: parseInt(passwdLine[0], 10), gid: parseInt(passwdLine[1], 10) };
202
+ uidGid = {
203
+ uid: parseInt(execFileSync("id", ["-u", linuxUser], { encoding: "utf8", stdio: "pipe" }).trim(), 10),
204
+ gid: parseInt(execFileSync("id", ["-g", linuxUser], { encoding: "utf8", stdio: "pipe" }).trim(), 10),
205
+ };
204
206
  } catch (e) {}
205
207
  if (uidGid) {
206
208
  fs.chmodSync(targetDir, 0o700);
207
- execSync("chown -R " + linuxUser + ":" + linuxUser + " " + JSON.stringify(targetDir));
208
- execSync("git init", { cwd: targetDir, uid: uidGid.uid, gid: uidGid.gid, env: { PATH: "/usr/local/bin:/usr/bin:/bin" } });
209
+ execFileSync("chown", ["-R", linuxUser + ":" + linuxUser, targetDir]);
210
+ execFileSync("git", ["init"], { cwd: targetDir, uid: uidGid.uid, gid: uidGid.gid, env: { PATH: "/usr/local/bin:/usr/bin:/bin" } });
209
211
  } else {
210
- execSync("git init", { cwd: targetDir });
212
+ execFileSync("git", ["init"], { cwd: targetDir });
211
213
  }
212
214
  } else {
213
- execSync("git init", { cwd: targetDir });
215
+ execFileSync("git", ["init"], { cwd: targetDir });
214
216
  }
215
217
  } else {
216
- execSync("git init", { cwd: targetDir });
218
+ execFileSync("git", ["init"], { cwd: targetDir });
217
219
  }
218
220
  } catch (e) {
219
221
  try { fs.rmSync(targetDir, { recursive: true, force: true }); } catch (ce) {}
@@ -245,7 +247,8 @@ var relay = createServer({
245
247
  },
246
248
  onCloneProject: function (cloneUrl, wsUser, callback) {
247
249
  var os = require("os");
248
- var { spawn, execSync } = require("child_process");
250
+ var spawn = require("child_process").spawn;
251
+ var execFileSync = require("child_process").execFileSync;
249
252
  var baseDir;
250
253
  if (config.osUsers) {
251
254
  baseDir = "/var/clay/projects";
@@ -262,9 +265,8 @@ var relay = createServer({
262
265
  var spawnOpts = { cwd: baseDir };
263
266
  if (config.osUsers && wsUser && wsUser.linuxUser) {
264
267
  try {
265
- var passwdLine = execSync("id -u " + wsUser.linuxUser + " && id -g " + wsUser.linuxUser, { encoding: "utf8" }).trim().split("\n");
266
- spawnOpts.uid = parseInt(passwdLine[0], 10);
267
- spawnOpts.gid = parseInt(passwdLine[1], 10);
268
+ spawnOpts.uid = parseInt(execFileSync("id", ["-u", wsUser.linuxUser], { encoding: "utf8", stdio: "pipe" }).trim(), 10);
269
+ spawnOpts.gid = parseInt(execFileSync("id", ["-g", wsUser.linuxUser], { encoding: "utf8", stdio: "pipe" }).trim(), 10);
268
270
  spawnOpts.env = Object.assign({}, process.env, {
269
271
  HOME: "/home/" + wsUser.linuxUser,
270
272
  USER: wsUser.linuxUser
@@ -304,7 +306,7 @@ var relay = createServer({
304
306
  if (config.osUsers && wsUser && wsUser.linuxUser) {
305
307
  try {
306
308
  fs.chmodSync(targetDir, 0o700);
307
- execSync("chown -R " + wsUser.linuxUser + ":" + wsUser.linuxUser + " " + JSON.stringify(targetDir));
309
+ execFileSync("chown", ["-R", wsUser.linuxUser + ":" + wsUser.linuxUser, targetDir]);
308
310
  } catch (e) {}
309
311
  }
310
312
  // Register project - creator always becomes owner
@@ -902,6 +904,47 @@ var relay = createServer({
902
904
  },
903
905
  });
904
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
+
905
948
  // Worktree tracking extracted to daemon-projects.js
906
949
 
907
950
  // --- Register projects ---
@@ -1303,41 +1346,48 @@ function spawnAndRestart() {
1303
1346
  try {
1304
1347
  updateHandoff = true;
1305
1348
  ipc.close();
1306
- relay.destroyAll();
1307
- if (relay.onboardingServer) relay.onboardingServer.close();
1349
+ shutdownProjects().then(function () {
1350
+ if (relay.onboardingServer) relay.onboardingServer.close();
1308
1351
 
1309
- // Close the server first so the port is released before spawning the new daemon
1310
- relay.server.close(function () {
1311
- try {
1312
- var { spawn: spawnRestart } = require("child_process");
1313
- var { logPath: restartLogPath, configPath: restartConfigPath } = require("./config");
1314
- var daemonScript = path.join(__dirname, "daemon.js");
1315
- var logFd = fs.openSync(restartLogPath(), "a");
1316
- var child = spawnRestart(process.execPath, [daemonScript], {
1317
- detached: true,
1318
- windowsHide: true,
1319
- stdio: ["ignore", logFd, logFd],
1320
- env: Object.assign({}, process.env, {
1321
- CLAY_CONFIG: restartConfigPath(),
1322
- }),
1323
- });
1324
- child.unref();
1325
- fs.closeSync(logFd);
1326
- config.pid = child.pid;
1327
- saveConfig(config);
1328
- console.log("[daemon] Spawned new daemon (PID " + child.pid + "), exiting.");
1329
- process.exit(120);
1330
- } catch (e) {
1331
- console.error("[daemon] Restart failed:", e.message);
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 () {
1332
1382
  process.exit(1);
1333
- }
1383
+ });
1334
1384
  });
1335
1385
 
1336
- // Force exit after 5 seconds if server.close hangs
1386
+ // Force exit after 10 seconds if server.close hangs
1337
1387
  setTimeout(function () {
1338
1388
  console.error("[daemon] Forced exit after timeout during restart");
1339
1389
  process.exit(1);
1340
- }, 5000);
1390
+ }, 10000);
1341
1391
  } catch (e) {
1342
1392
  console.error("[daemon] Restart failed:", e.message);
1343
1393
  relay.broadcastAll({ type: "toast", level: "error", message: "Restart failed: " + e.message });
@@ -1347,9 +1397,12 @@ function spawnAndRestart() {
1347
1397
 
1348
1398
  // --- Graceful shutdown ---
1349
1399
  var updateHandoff = false; // true when shutting down for update (new daemon already spawned)
1400
+ var shutdownStarted = false;
1350
1401
 
1351
1402
  function gracefulShutdown() {
1352
1403
  try { console.log("[daemon] Shutting down..."); } catch (e) {}
1404
+ if (shutdownStarted) return;
1405
+ shutdownStarted = true;
1353
1406
  var exitCode = updateHandoff ? 120 : 0; // 120 = update handoff, don't auto-restart
1354
1407
 
1355
1408
  if (caffeinateProc) {
@@ -1369,22 +1422,30 @@ function gracefulShutdown() {
1369
1422
  } catch (e) {}
1370
1423
  }
1371
1424
 
1372
- relay.destroyAll();
1373
-
1374
- if (relay.onboardingServer) {
1375
- relay.onboardingServer.close();
1376
- }
1425
+ shutdownProjects().then(function () {
1426
+ if (relay.onboardingServer) {
1427
+ relay.onboardingServer.close();
1428
+ }
1377
1429
 
1378
- relay.server.close(function () {
1379
- try { console.log("[daemon] Server closed"); } catch (e) {}
1380
- process.exit(exitCode);
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
+ });
1381
1442
  });
1382
1443
 
1383
- // Force exit after 5 seconds
1444
+ // Force exit after 10 seconds
1384
1445
  setTimeout(function () {
1385
1446
  try { console.error("[daemon] Forced exit after timeout"); } catch (e) {}
1386
1447
  process.exit(1);
1387
- }, 5000);
1448
+ }, 10000);
1388
1449
  }
1389
1450
 
1390
1451
  process.on("SIGTERM", gracefulShutdown);