clay-server 2.26.0 → 2.27.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.
@@ -0,0 +1,1152 @@
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var { execFileSync } = require("child_process");
4
+
5
+ /**
6
+ * Attach session management, config, project management, and mid-section
7
+ * message handlers to a project context.
8
+ *
9
+ * ctx fields:
10
+ * cwd, slug, isMate, osUsers, debug, dangerouslySkipPermissions, currentVersion,
11
+ * sm, sdk, tm, clients,
12
+ * send, sendTo, sendToAdmins, sendToSession, sendToSessionOthers,
13
+ * opts, usersModule, userPresence, matesModule, pushModule,
14
+ * getSessionForWs, getLinuxUserForSession, getOsUserInfoForWs,
15
+ * hydrateImageRefs, onProcessingChanged, broadcastPresence,
16
+ * getSDK, getProjectList, getProjectCount, getScheduleCount,
17
+ * moveScheduleToProject, moveAllSchedulesToProject, getHubSchedules,
18
+ * fetchVersion, isNewer, onCreateWorktree, IGNORED_DIRS,
19
+ * scheduleMessage, cancelScheduledMessage,
20
+ * getProjectOwnerId, setProjectOwnerId,
21
+ * getUpdateChannel, setUpdateChannel,
22
+ * getLatestVersion, setLatestVersion
23
+ */
24
+ function attachSessions(ctx) {
25
+ var cwd = ctx.cwd;
26
+ var slug = ctx.slug;
27
+ var isMate = ctx.isMate;
28
+ var osUsers = ctx.osUsers;
29
+ var currentVersion = ctx.currentVersion;
30
+ var sm = ctx.sm;
31
+ var sdk = ctx.sdk;
32
+ var tm = ctx.tm;
33
+ var clients = ctx.clients;
34
+ var send = ctx.send;
35
+ var sendTo = ctx.sendTo;
36
+ var sendToAdmins = ctx.sendToAdmins;
37
+ var sendToSession = ctx.sendToSession;
38
+ var sendToSessionOthers = ctx.sendToSessionOthers;
39
+ var opts = ctx.opts;
40
+ var usersModule = ctx.usersModule;
41
+ var userPresence = ctx.userPresence;
42
+ var pushModule = ctx.pushModule;
43
+ var getSessionForWs = ctx.getSessionForWs;
44
+ var getLinuxUserForSession = ctx.getLinuxUserForSession;
45
+ var getOsUserInfoForWs = ctx.getOsUserInfoForWs;
46
+ var hydrateImageRefs = ctx.hydrateImageRefs;
47
+ var onProcessingChanged = ctx.onProcessingChanged;
48
+ var broadcastPresence = ctx.broadcastPresence;
49
+ var getSDK = ctx.getSDK;
50
+ var getProjectList = ctx.getProjectList;
51
+ var getProjectCount = ctx.getProjectCount;
52
+ var getScheduleCount = ctx.getScheduleCount;
53
+ var moveScheduleToProject = ctx.moveScheduleToProject;
54
+ var moveAllSchedulesToProject = ctx.moveAllSchedulesToProject;
55
+ var getHubSchedules = ctx.getHubSchedules;
56
+ var fetchVersion = ctx.fetchVersion;
57
+ var isNewer = ctx.isNewer;
58
+ var onCreateWorktree = ctx.onCreateWorktree;
59
+ var IGNORED_DIRS = ctx.IGNORED_DIRS;
60
+ var scheduleMessage = ctx.scheduleMessage;
61
+ var cancelScheduledMessage = ctx.cancelScheduledMessage;
62
+ var getProjectOwnerId = ctx.getProjectOwnerId;
63
+ var setProjectOwnerId = ctx.setProjectOwnerId;
64
+ var getUpdateChannel = ctx.getUpdateChannel;
65
+ var setUpdateChannel = ctx.setUpdateChannel;
66
+ var getLatestVersion = ctx.getLatestVersion;
67
+ var setLatestVersion = ctx.setLatestVersion;
68
+
69
+ function handleSessionsMessage(ws, msg) {
70
+
71
+ if (msg.type === "push_subscribe") {
72
+ var _pushUserId = ws._clayUser ? ws._clayUser.id : null;
73
+ if (pushModule && msg.subscription) pushModule.addSubscription(msg.subscription, msg.replaceEndpoint, _pushUserId);
74
+ return true;
75
+ }
76
+
77
+ if (msg.type === "load_more_history") {
78
+ var session = getSessionForWs(ws);
79
+ if (!session || typeof msg.before !== "number") return true;
80
+ var before = msg.before;
81
+ var targetFrom = typeof msg.target === "number" ? msg.target : before - sm.HISTORY_PAGE_SIZE;
82
+ var from = sm.findTurnBoundary(session.history, Math.max(0, targetFrom));
83
+ var to = before;
84
+ var items = session.history.slice(from, to).map(hydrateImageRefs);
85
+ sendTo(ws, {
86
+ type: "history_prepend",
87
+ items: items,
88
+ meta: { from: from, to: to, hasMore: from > 0 },
89
+ });
90
+ return true;
91
+ }
92
+
93
+ if (msg.type === "new_session") {
94
+ var sessionOpts = {};
95
+ if (ws._clayUser && usersModule.isMultiUser()) sessionOpts.ownerId = ws._clayUser.id;
96
+ if (msg.sessionVisibility) sessionOpts.sessionVisibility = msg.sessionVisibility;
97
+ var newSess = sm.createSession(sessionOpts, ws);
98
+ ws._clayActiveSession = newSess.localId;
99
+ var nsPresKey = ws._clayUser ? ws._clayUser.id : "_default";
100
+ userPresence.setPresence(slug, nsPresKey, newSess.localId, null);
101
+ if (usersModule.isMultiUser()) {
102
+ broadcastPresence();
103
+ }
104
+ return true;
105
+ }
106
+
107
+ if (msg.type === "set_session_visibility") {
108
+ if (typeof msg.sessionId === "number" && (msg.visibility === "shared" || msg.visibility === "private")) {
109
+ sm.setSessionVisibility(msg.sessionId, msg.visibility);
110
+ }
111
+ return true;
112
+ }
113
+
114
+ if (msg.type === "transfer_project_owner") {
115
+ var projectOwnerId = getProjectOwnerId();
116
+ var isAdmin = ws._clayUser && ws._clayUser.role === "admin";
117
+ var isProjectOwner = ws._clayUser && projectOwnerId && ws._clayUser.id === projectOwnerId;
118
+ if (!ws._clayUser || (!isAdmin && !isProjectOwner)) {
119
+ sendTo(ws, { type: "error", text: "Only project owners or admins can transfer ownership." });
120
+ return true;
121
+ }
122
+ var targetUser = msg.userId ? usersModule.findUserById(msg.userId) : null;
123
+ if (!targetUser) {
124
+ sendTo(ws, { type: "error", text: "User not found." });
125
+ return true;
126
+ }
127
+ setProjectOwnerId(targetUser.id);
128
+ // Persist via daemon callback
129
+ if (opts.onProjectOwnerChanged) {
130
+ opts.onProjectOwnerChanged(slug, targetUser.id);
131
+ }
132
+ send({ type: "project_owner_changed", ownerId: targetUser.id, ownerName: targetUser.displayName || targetUser.username });
133
+ return true;
134
+ }
135
+
136
+ if (msg.type === "resume_session") {
137
+ if (!msg.cliSessionId) return true;
138
+ var cliSess = require("./cli-sessions");
139
+ // Try SDK for title first, then fall back to manual parsing
140
+ var titlePromise = getSDK().then(function(sdkMod) {
141
+ return sdkMod.getSessionInfo(msg.cliSessionId, { dir: cwd });
142
+ }).then(function(info) {
143
+ return (info && info.summary) ? info.summary.substring(0, 100) : null;
144
+ }).catch(function() { return null; });
145
+
146
+ Promise.all([
147
+ cliSess.readCliSessionHistory(cwd, msg.cliSessionId),
148
+ titlePromise
149
+ ]).then(function(results) {
150
+ var history = results[0];
151
+ var sdkTitle = results[1];
152
+ var title = sdkTitle || "Resumed session";
153
+ if (!sdkTitle) {
154
+ for (var i = 0; i < history.length; i++) {
155
+ if (history[i].type === "user_message" && history[i].text) {
156
+ title = history[i].text.substring(0, 50);
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ var resumed = sm.resumeSession(msg.cliSessionId, { history: history, title: title }, ws);
162
+ if (resumed) ws._clayActiveSession = resumed.localId;
163
+ }).catch(function() {
164
+ var resumed = sm.resumeSession(msg.cliSessionId, undefined, ws);
165
+ if (resumed) ws._clayActiveSession = resumed.localId;
166
+ });
167
+ return true;
168
+ }
169
+
170
+ if (msg.type === "list_cli_sessions") {
171
+ var _fs = require("fs");
172
+ // Collect session IDs already in relay (in-memory + persisted on disk)
173
+ var relayIds = {};
174
+ sm.sessions.forEach(function (s) {
175
+ if (s.cliSessionId) relayIds[s.cliSessionId] = true;
176
+ });
177
+ try {
178
+ var sessDir = sm.sessionsDir;
179
+ var diskFiles = _fs.readdirSync(sessDir);
180
+ for (var fi = 0; fi < diskFiles.length; fi++) {
181
+ if (diskFiles[fi].endsWith(".jsonl")) {
182
+ relayIds[diskFiles[fi].replace(".jsonl", "")] = true;
183
+ }
184
+ }
185
+ } catch (e) {}
186
+
187
+ getSDK().then(function(sdkMod) {
188
+ return sdkMod.listSessions({ dir: cwd });
189
+ }).then(function(sdkSessions) {
190
+ var filtered = sdkSessions.filter(function(s) {
191
+ return !relayIds[s.sessionId];
192
+ }).map(function(s) {
193
+ return {
194
+ sessionId: s.sessionId,
195
+ firstPrompt: s.summary || s.firstPrompt || "",
196
+ model: null,
197
+ gitBranch: s.gitBranch || null,
198
+ startTime: s.createdAt ? new Date(s.createdAt).toISOString() : null,
199
+ lastActivity: s.lastModified ? new Date(s.lastModified).toISOString() : null,
200
+ };
201
+ });
202
+ sendTo(ws, { type: "cli_session_list", sessions: filtered });
203
+ }).catch(function() {
204
+ // Fallback to manual parsing if SDK fails
205
+ var cliSessions = require("./cli-sessions");
206
+ cliSessions.listCliSessions(cwd).then(function(sessions) {
207
+ var filtered = sessions.filter(function(s) {
208
+ return !relayIds[s.sessionId];
209
+ });
210
+ sendTo(ws, { type: "cli_session_list", sessions: filtered });
211
+ }).catch(function() {
212
+ sendTo(ws, { type: "cli_session_list", sessions: [] });
213
+ });
214
+ });
215
+ return true;
216
+ }
217
+
218
+ if (msg.type === "switch_session") {
219
+ if (msg.id && sm.sessions.has(msg.id)) {
220
+ // Check access in multi-user mode
221
+ if (usersModule.isMultiUser() && ws._clayUser) {
222
+ var switchTarget = sm.sessions.get(msg.id);
223
+ if (!usersModule.canAccessSession(ws._clayUser.id, switchTarget, { visibility: "public" })) return true;
224
+ ws._clayActiveSession = msg.id;
225
+ sm.switchSession(msg.id, ws, hydrateImageRefs);
226
+ broadcastPresence();
227
+ } else {
228
+ ws._clayActiveSession = msg.id;
229
+ sm.switchSession(msg.id, ws, hydrateImageRefs);
230
+ }
231
+ var swPresKey = ws._clayUser ? ws._clayUser.id : "_default";
232
+ userPresence.setPresence(slug, swPresKey, msg.id, null);
233
+ }
234
+ return true;
235
+ }
236
+
237
+ if (msg.type === "set_mate_dm") {
238
+ // Only store mateDm on non-mate projects (main project presence).
239
+ // Mate projects should never hold mateDm to avoid circular restore loops.
240
+ if (!isMate) {
241
+ var dmPresKey = ws._clayUser ? ws._clayUser.id : "_default";
242
+ userPresence.setMateDm(slug, dmPresKey, msg.mateId || null);
243
+ }
244
+ return true;
245
+ }
246
+
247
+ if (msg.type === "delete_session") {
248
+ if (ws._clayUser) {
249
+ var sdPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
250
+ if (!sdPerms.sessionDelete) {
251
+ sendTo(ws, { type: "error", text: "You do not have permission to delete sessions" });
252
+ return true;
253
+ }
254
+ }
255
+ if (msg.id && sm.sessions.has(msg.id)) {
256
+ sm.deleteSession(msg.id, ws);
257
+ }
258
+ return true;
259
+ }
260
+
261
+ if (msg.type === "rename_session") {
262
+ if (msg.id && sm.sessions.has(msg.id) && msg.title) {
263
+ var s = sm.sessions.get(msg.id);
264
+ s.title = String(msg.title).substring(0, 100);
265
+ sm.saveSessionFile(s);
266
+ sm.broadcastSessionList();
267
+ // Sync title to SDK session
268
+ if (s.cliSessionId) {
269
+ getSDK().then(function(sdkInst) {
270
+ sdkInst.renameSession(s.cliSessionId, s.title, { dir: cwd }).catch(function(e) {
271
+ console.error("[project] SDK renameSession failed:", e.message);
272
+ });
273
+ }).catch(function() {});
274
+ }
275
+ }
276
+ return true;
277
+ }
278
+
279
+ if (msg.type === "search_sessions") {
280
+ var results = sm.searchSessions(msg.query || "");
281
+ sendTo(ws, { type: "search_results", query: msg.query || "", results: results });
282
+ return true;
283
+ }
284
+
285
+ if (msg.type === "search_session_content") {
286
+ var targetSession = msg.id ? sm.sessions.get(msg.id) : getSessionForWs(ws);
287
+ if (!targetSession) return true;
288
+ var contentResults = sm.searchSessionContent(targetSession.localId, msg.query || "");
289
+ var searchResp = { type: "search_content_results", query: msg.query || "", sessionId: targetSession.localId, hits: contentResults.hits, total: contentResults.total };
290
+ if (msg.source) searchResp.source = msg.source;
291
+ sendTo(ws, searchResp);
292
+ return true;
293
+ }
294
+
295
+ if (msg.type === "set_update_channel") {
296
+ if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return true;
297
+ var newChannel = msg.channel === "beta" ? "beta" : "stable";
298
+ setUpdateChannel(newChannel);
299
+ setLatestVersion(null);
300
+ if (typeof opts.onSetUpdateChannel === "function") {
301
+ opts.onSetUpdateChannel(newChannel);
302
+ }
303
+ // Re-fetch with new channel and broadcast to admin clients
304
+ fetchVersion(newChannel).then(function (v) {
305
+ if (v && isNewer(v, currentVersion)) {
306
+ setLatestVersion(v);
307
+ sendToAdmins({ type: "update_available", version: v });
308
+ }
309
+ }).catch(function () {});
310
+ return true;
311
+ }
312
+
313
+ if (msg.type === "check_update") {
314
+ if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return true;
315
+ var updateChannel = getUpdateChannel();
316
+ fetchVersion(updateChannel).then(function (v) {
317
+ if (v && isNewer(v, currentVersion)) {
318
+ setLatestVersion(v);
319
+ sendTo(ws, { type: "update_available", version: v });
320
+ } else {
321
+ sendTo(ws, { type: "up_to_date", version: currentVersion });
322
+ }
323
+ }).catch(function () {});
324
+ return true;
325
+ }
326
+
327
+ if (msg.type === "update_now") {
328
+ if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return true;
329
+ send({ type: "update_started", version: getLatestVersion() || "" });
330
+ var _ipc = require("./ipc");
331
+ var _config = require("./config");
332
+ _ipc.sendIPCCommand(_config.socketPath(), { cmd: "update" });
333
+ return true;
334
+ }
335
+
336
+ if (msg.type === "process_stats") {
337
+ var sessionCount = sm.sessions.size;
338
+ var processingCount = 0;
339
+ sm.sessions.forEach(function (s) {
340
+ if (s.isProcessing) processingCount++;
341
+ });
342
+ var mem = process.memoryUsage();
343
+ sendTo(ws, {
344
+ type: "process_stats",
345
+ pid: process.pid,
346
+ uptime: process.uptime(),
347
+ memory: {
348
+ rss: mem.rss,
349
+ heapUsed: mem.heapUsed,
350
+ heapTotal: mem.heapTotal,
351
+ external: mem.external,
352
+ },
353
+ sessions: sessionCount,
354
+ processing: processingCount,
355
+ clients: clients.size,
356
+ terminals: tm.list().length,
357
+ });
358
+ return true;
359
+ }
360
+
361
+ if (msg.type === "stop") {
362
+ var session = getSessionForWs(ws);
363
+ if (session && session.abortController && session.isProcessing) {
364
+ session.abortController.abort();
365
+ }
366
+ return true;
367
+ }
368
+
369
+ if (msg.type === "stop_task") {
370
+ if (msg.taskId) {
371
+ sdk.stopTask(msg.taskId);
372
+ }
373
+ return true;
374
+ }
375
+
376
+ if (msg.type === "kill_process") {
377
+ var pid = msg.pid;
378
+ if (!pid || typeof pid !== "number") return true;
379
+ // Verify target is actually a claude process before killing
380
+ if (!sdk.isClaudeProcess(pid)) {
381
+ console.error("[project] Refused to kill PID " + pid + ": not a claude process");
382
+ sendTo(ws, { type: "error", text: "Process " + pid + " is not a Claude process." });
383
+ return true;
384
+ }
385
+ try {
386
+ process.kill(pid, "SIGTERM");
387
+ console.log("[project] Sent SIGTERM to conflicting Claude process PID " + pid);
388
+ sendTo(ws, { type: "process_killed", pid: pid });
389
+ } catch (e) {
390
+ console.error("[project] Failed to kill PID " + pid + ":", e.message);
391
+ sendTo(ws, { type: "error", text: "Failed to kill process " + pid + ": " + (e.message || e) });
392
+ }
393
+ return true;
394
+ }
395
+
396
+ if (msg.type === "set_model" && msg.model) {
397
+ var session = getSessionForWs(ws);
398
+ if (session) {
399
+ sdk.setModel(session, msg.model);
400
+ }
401
+ return true;
402
+ }
403
+
404
+ if (msg.type === "set_server_default_model" && msg.model) {
405
+ if (typeof opts.onSetServerDefaultModel === "function") {
406
+ opts.onSetServerDefaultModel(msg.model);
407
+ }
408
+ var session = getSessionForWs(ws);
409
+ if (session) {
410
+ sdk.setModel(session, msg.model);
411
+ }
412
+ return true;
413
+ }
414
+
415
+ if (msg.type === "set_project_default_model" && msg.model) {
416
+ if (typeof opts.onSetProjectDefaultModel === "function") {
417
+ opts.onSetProjectDefaultModel(slug, msg.model);
418
+ }
419
+ var session = getSessionForWs(ws);
420
+ if (session) {
421
+ sdk.setModel(session, msg.model);
422
+ }
423
+ return true;
424
+ }
425
+
426
+ if (msg.type === "set_permission_mode" && msg.mode) {
427
+ sm.currentPermissionMode = msg.mode;
428
+ var session = getSessionForWs(ws);
429
+ if (session) {
430
+ sdk.setPermissionMode(session, msg.mode);
431
+ }
432
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
433
+ return true;
434
+ }
435
+
436
+ if (msg.type === "set_server_default_mode" && msg.mode) {
437
+ if (typeof opts.onSetServerDefaultMode === "function") {
438
+ opts.onSetServerDefaultMode(msg.mode);
439
+ }
440
+ sm.currentPermissionMode = msg.mode;
441
+ var session = getSessionForWs(ws);
442
+ if (session) {
443
+ sdk.setPermissionMode(session, msg.mode);
444
+ }
445
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
446
+ return true;
447
+ }
448
+
449
+ if (msg.type === "set_project_default_mode" && msg.mode) {
450
+ if (typeof opts.onSetProjectDefaultMode === "function") {
451
+ opts.onSetProjectDefaultMode(slug, msg.mode);
452
+ }
453
+ sm.currentPermissionMode = msg.mode;
454
+ var session = getSessionForWs(ws);
455
+ if (session) {
456
+ sdk.setPermissionMode(session, msg.mode);
457
+ }
458
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
459
+ return true;
460
+ }
461
+
462
+ if (msg.type === "set_effort" && msg.effort) {
463
+ sm.currentEffort = msg.effort;
464
+ var session = getSessionForWs(ws);
465
+ if (session) {
466
+ sdk.setEffort(session, msg.effort);
467
+ }
468
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
469
+ return true;
470
+ }
471
+
472
+ if (msg.type === "set_server_default_effort" && msg.effort) {
473
+ if (typeof opts.onSetServerDefaultEffort === "function") {
474
+ opts.onSetServerDefaultEffort(msg.effort);
475
+ }
476
+ sm.currentEffort = msg.effort;
477
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
478
+ return true;
479
+ }
480
+
481
+ if (msg.type === "set_project_default_effort" && msg.effort) {
482
+ if (typeof opts.onSetProjectDefaultEffort === "function") {
483
+ opts.onSetProjectDefaultEffort(slug, msg.effort);
484
+ }
485
+ sm.currentEffort = msg.effort;
486
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
487
+ return true;
488
+ }
489
+
490
+ if (msg.type === "set_betas") {
491
+ sm.currentBetas = msg.betas || [];
492
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas, thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
493
+ return true;
494
+ }
495
+
496
+ if (msg.type === "set_thinking") {
497
+ sm.currentThinking = msg.thinking || "adaptive";
498
+ if (msg.budgetTokens) sm.currentThinkingBudget = msg.budgetTokens;
499
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
500
+ return true;
501
+ }
502
+
503
+ if (msg.type === "rewind_preview") {
504
+ var session = getSessionForWs(ws);
505
+ if (!session || !session.cliSessionId || !msg.uuid) return true;
506
+ // Reject preview requests while a rewind is executing
507
+ if (session._rewindInProgress) return true;
508
+
509
+ (async function () {
510
+ var result;
511
+ try {
512
+ result = await sdk.getOrCreateRewindQuery(session);
513
+ var preview = await result.query.rewindFiles(msg.uuid, { dryRun: true });
514
+ var diffs = {};
515
+ var changedFiles = preview.filesChanged || [];
516
+ for (var f = 0; f < changedFiles.length; f++) {
517
+ try {
518
+ diffs[changedFiles[f]] = execFileSync(
519
+ "git", ["diff", "HEAD", "--", changedFiles[f]],
520
+ { cwd: cwd, encoding: "utf8", timeout: 5000 }
521
+ ) || "";
522
+ } catch (e) { diffs[changedFiles[f]] = ""; }
523
+ }
524
+ sendTo(ws, { type: "rewind_preview_result", preview: preview, diffs: diffs, uuid: msg.uuid });
525
+ } catch (err) {
526
+ sendTo(ws, { type: "rewind_error", text: "Failed to preview rewind: " + err.message });
527
+ } finally {
528
+ if (result && result.isTemp) result.cleanup();
529
+ }
530
+ })();
531
+ return true;
532
+ }
533
+
534
+ if (msg.type === "rewind_execute") {
535
+ var session = getSessionForWs(ws);
536
+ if (!session || !session.cliSessionId || !msg.uuid) return true;
537
+ // Guard against concurrent rewind executions
538
+ if (session._rewindInProgress) {
539
+ sendTo(ws, { type: "rewind_error", text: "Rewind already in progress." });
540
+ return true;
541
+ }
542
+ session._rewindInProgress = true;
543
+ var mode = msg.mode || "both";
544
+
545
+ (async function () {
546
+ var result;
547
+ try {
548
+ // File restoration (skip for chat-only mode)
549
+ if (mode !== "chat") {
550
+ result = await sdk.getOrCreateRewindQuery(session);
551
+ await result.query.rewindFiles(msg.uuid, { dryRun: false });
552
+ }
553
+
554
+ // Conversation rollback (skip for files-only mode)
555
+ if (mode !== "files") {
556
+ var targetIdx = -1;
557
+ for (var i = 0; i < session.messageUUIDs.length; i++) {
558
+ if (session.messageUUIDs[i].uuid === msg.uuid) {
559
+ targetIdx = i;
560
+ break;
561
+ }
562
+ }
563
+
564
+ if (targetIdx >= 0) {
565
+ var trimTo = session.messageUUIDs[targetIdx].historyIndex;
566
+ for (var k = trimTo - 1; k >= 0; k--) {
567
+ if (session.history[k].type === "user_message") {
568
+ trimTo = k;
569
+ break;
570
+ }
571
+ }
572
+ session.history = session.history.slice(0, trimTo);
573
+ session.messageUUIDs = session.messageUUIDs.slice(0, targetIdx);
574
+ }
575
+
576
+ var kept = session.messageUUIDs;
577
+ session.lastRewindUuid = kept.length > 0 ? kept[kept.length - 1].uuid : null;
578
+ }
579
+
580
+ if (session.abortController) {
581
+ try { session.abortController.abort(); } catch (e) {}
582
+ }
583
+ if (session.messageQueue) {
584
+ try { session.messageQueue.end(); } catch (e) {}
585
+ }
586
+ session.queryInstance = null;
587
+ session.messageQueue = null;
588
+ session.abortController = null;
589
+ session.blocks = {};
590
+ session.sentToolResults = {};
591
+ session.pendingPermissions = {};
592
+ session.pendingAskUser = {};
593
+ session.isProcessing = false;
594
+ onProcessingChanged();
595
+
596
+ sm.saveSessionFile(session);
597
+ sm.switchSession(session.localId, ws, hydrateImageRefs);
598
+ sm.sendAndRecord(session, { type: "rewind_complete", mode: mode });
599
+ sm.broadcastSessionList();
600
+ } catch (err) {
601
+ sendTo(ws, { type: "rewind_error", text: "Rewind failed: " + err.message });
602
+ } finally {
603
+ session._rewindInProgress = false;
604
+ if (result && result.isTemp) result.cleanup();
605
+ }
606
+ })();
607
+ return true;
608
+ }
609
+
610
+ if (msg.type === "fork_session" && msg.uuid) {
611
+ var session = getSessionForWs(ws);
612
+ if (!session || !session.cliSessionId) {
613
+ sendTo(ws, { type: "error", text: "Cannot fork: no CLI session" });
614
+ return true;
615
+ }
616
+ var forkCliId = session.cliSessionId;
617
+ var forkTitle = (session.title || "New Session") + " (fork)";
618
+ getSDK().then(function(sdkMod) {
619
+ return sdkMod.forkSession(forkCliId, {
620
+ upToMessageId: msg.uuid,
621
+ dir: cwd,
622
+ });
623
+ }).then(function(result) {
624
+ var cliSess = require("./cli-sessions");
625
+ return cliSess.readCliSessionHistory(cwd, result.sessionId).then(function(history) {
626
+ var forked = sm.resumeSession(result.sessionId, { history: history, title: forkTitle }, ws);
627
+ if (forked) {
628
+ ws._clayActiveSession = forked.localId;
629
+ sendTo(ws, { type: "fork_complete", sessionId: forked.localId });
630
+ }
631
+ });
632
+ }).catch(function(e) {
633
+ sendTo(ws, { type: "error", text: "Fork failed: " + (e.message || e) });
634
+ });
635
+ return true;
636
+ }
637
+
638
+ if (msg.type === "ask_user_response") {
639
+ var session = getSessionForWs(ws);
640
+ if (!session) return true;
641
+ var toolId = msg.toolId;
642
+ var answers = msg.answers || {};
643
+ var pending = session.pendingAskUser[toolId];
644
+ if (!pending) return true;
645
+ delete session.pendingAskUser[toolId];
646
+ sm.sendAndRecord(session, { type: "ask_user_answered", toolId: toolId, answers: answers });
647
+ pending.resolve({
648
+ behavior: "allow",
649
+ updatedInput: Object.assign({}, pending.input, { answers: answers }),
650
+ });
651
+ return true;
652
+ }
653
+
654
+ if (msg.type === "input_sync") {
655
+ sendToSessionOthers(ws, ws._clayActiveSession, msg);
656
+ return true;
657
+ }
658
+
659
+ if (msg.type === "cursor_move" || msg.type === "cursor_leave" || msg.type === "text_select") {
660
+ if (!usersModule.isMultiUser() || !ws._clayUser) return true;
661
+ var u = ws._clayUser;
662
+ var p = u.profile || {};
663
+ var cursorMsg = {
664
+ type: msg.type,
665
+ userId: u.id,
666
+ displayName: p.name || u.displayName || u.username,
667
+ avatarStyle: p.avatarStyle || "thumbs",
668
+ avatarSeed: p.avatarSeed || u.username,
669
+ avatarCustom: p.avatarCustom || "",
670
+ };
671
+ if (msg.type === "cursor_move") {
672
+ cursorMsg.turn = msg.turn;
673
+ if (msg.rx != null) cursorMsg.rx = msg.rx;
674
+ if (msg.ry != null) cursorMsg.ry = msg.ry;
675
+ }
676
+ if (msg.type === "text_select") {
677
+ cursorMsg.ranges = msg.ranges || [];
678
+ }
679
+ sendToSessionOthers(ws, ws._clayActiveSession, cursorMsg);
680
+ return true;
681
+ }
682
+
683
+ if (msg.type === "permission_response") {
684
+ var session = getSessionForWs(ws);
685
+ if (!session) return true;
686
+ var requestId = msg.requestId;
687
+ var decision = msg.decision;
688
+ var pending = session.pendingPermissions[requestId];
689
+ if (!pending) return true;
690
+ delete session.pendingPermissions[requestId];
691
+ onProcessingChanged(); // update cross-project permission badge
692
+
693
+ // --- Plan approval: "allow_accept_edits" -- approve + switch to acceptEdits mode ---
694
+ if (decision === "allow_accept_edits") {
695
+ sdk.setPermissionMode(session, "acceptEdits");
696
+ sm.currentPermissionMode = "acceptEdits";
697
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
698
+ pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
699
+ sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
700
+ return true;
701
+ }
702
+
703
+ // --- Plan approval: "allow_clear_context" -- new session + plan as first message + acceptEdits ---
704
+ if (decision === "allow_clear_context") {
705
+ // Deny current plan to end the turn
706
+ pending.resolve({ behavior: "deny", message: "User chose to clear context and restart" });
707
+ sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
708
+
709
+ // Abort the old session's query -- but defer to next tick so the SDK's
710
+ // deny write (scheduled as microtask by pending.resolve) completes first.
711
+ // Aborting synchronously would kill the subprocess before the write,
712
+ // causing an "Operation aborted" crash in the SDK.
713
+ session.isProcessing = false;
714
+ onProcessingChanged();
715
+ session.pendingPermissions = {};
716
+ session.pendingAskUser = {};
717
+ sm.broadcastSessionList();
718
+ setImmediate(function () {
719
+ if (session.abortController) {
720
+ session.abortController.abort();
721
+ }
722
+ });
723
+
724
+ // Update permission mode for the new session
725
+ sm.currentPermissionMode = "acceptEdits";
726
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
727
+
728
+ // Build prompt from plan content (sent from client) or plan file path
729
+ var clientPlanContent = msg.planContent || "";
730
+ var planPrompt;
731
+ if (clientPlanContent) {
732
+ planPrompt = "Execute the following plan. Do NOT re-enter plan mode -- just implement it step by step.\n\n" + clientPlanContent;
733
+ } else {
734
+ var planFilePath = (pending.toolInput && pending.toolInput.planFilePath) || "";
735
+ planPrompt = "Execute the plan in " + planFilePath + ". Do NOT re-enter plan mode -- read the plan file and implement it step by step.";
736
+ }
737
+
738
+ // Wait for old query stream to fully terminate, then create new session + send plan
739
+ var oldStreamPromise = session.streamPromise || Promise.resolve();
740
+ Promise.race([
741
+ oldStreamPromise,
742
+ new Promise(function (resolve) { setTimeout(resolve, 3000); }),
743
+ ]).then(function () {
744
+ try {
745
+ var newSession = sm.createSession(null, ws);
746
+ // Send the plan as the first user message (with planContent for UI rendering)
747
+ var userMsg = { type: "user_message", text: planPrompt, planContent: clientPlanContent || null };
748
+ newSession.history.push(userMsg);
749
+ sm.appendToSessionFile(newSession, userMsg);
750
+ newSession.title = "Plan execution (cleared context)";
751
+ sm.saveSessionFile(newSession);
752
+ sm.broadcastSessionList();
753
+ sendToSession(newSession.localId, userMsg);
754
+
755
+ newSession.isProcessing = true;
756
+ onProcessingChanged();
757
+ newSession.sentToolResults = {};
758
+ sendToSession(newSession.localId, { type: "status", status: "processing" });
759
+ newSession.acceptEditsAfterStart = true;
760
+ sdk.startQuery(newSession, planPrompt, undefined, getLinuxUserForSession(newSession));
761
+ } catch (e) {
762
+ console.error("[project] Error starting plan execution:", e);
763
+ sendTo(ws, { type: "error", text: "Failed to start plan execution: " + (e.message || e) });
764
+ }
765
+ }).catch(function (e) {
766
+ console.error("[project] Plan execution stream wait failed:", e.message || e);
767
+ });
768
+ return true;
769
+ }
770
+
771
+ // --- Plan approval: "deny_with_feedback" -- deny + send feedback as follow-up message ---
772
+ if (decision === "deny_with_feedback") {
773
+ var feedback = msg.feedback || "";
774
+ pending.resolve({ behavior: "deny", message: feedback || "User provided feedback" });
775
+ sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
776
+
777
+ // Send feedback as next user message if there's text
778
+ if (feedback) {
779
+ setTimeout(function () {
780
+ var userMsg = { type: "user_message", text: feedback };
781
+ session.history.push(userMsg);
782
+ sm.appendToSessionFile(session, userMsg);
783
+ sendToSession(session.localId, userMsg);
784
+
785
+ if (!session.isProcessing) {
786
+ session.isProcessing = true;
787
+ onProcessingChanged();
788
+ session.sentToolResults = {};
789
+ sendToSession(session.localId, { type: "status", status: "processing" });
790
+ if (!session.queryInstance && !session.worker) {
791
+ sdk.startQuery(session, feedback, undefined, getLinuxUserForSession(session));
792
+ } else {
793
+ sdk.pushMessage(session, feedback);
794
+ }
795
+ } else {
796
+ sdk.pushMessage(session, feedback);
797
+ }
798
+ }, 200);
799
+ }
800
+ return true;
801
+ }
802
+
803
+ if (decision === "allow" || decision === "allow_always") {
804
+ if (decision === "allow_always") {
805
+ if (!session.allowedTools) session.allowedTools = {};
806
+ session.allowedTools[pending.toolName] = true;
807
+ }
808
+ pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
809
+ } else {
810
+ pending.resolve({ behavior: "deny", message: "User denied permission" });
811
+ }
812
+
813
+ sm.sendAndRecord(session, {
814
+ type: "permission_resolved",
815
+ requestId: requestId,
816
+ decision: decision,
817
+ });
818
+ return true;
819
+ }
820
+
821
+ // --- MCP elicitation response ---
822
+ if (msg.type === "elicitation_response") {
823
+ var session = getSessionForWs(ws);
824
+ if (!session) return true;
825
+ var pending = session.pendingElicitations && session.pendingElicitations[msg.requestId];
826
+ if (!pending) return true;
827
+ delete session.pendingElicitations[msg.requestId];
828
+ if (msg.action === "accept") {
829
+ pending.resolve({ action: "accept", content: msg.content || {} });
830
+ } else {
831
+ pending.resolve({ action: "reject" });
832
+ }
833
+ sm.sendAndRecord(session, {
834
+ type: "elicitation_resolved",
835
+ requestId: msg.requestId,
836
+ action: msg.action,
837
+ });
838
+ return true;
839
+ }
840
+
841
+ // --- Browse directories (for add-project autocomplete) ---
842
+ if (msg.type === "browse_dir") {
843
+ var rawPath = (msg.path || "").replace(/^~/, require("./config").REAL_HOME);
844
+ var absTarget = path.resolve(rawPath);
845
+ var parentDir, prefix;
846
+ try {
847
+ var stat = fs.statSync(absTarget);
848
+ if (stat.isDirectory()) {
849
+ // Input is an existing directory -- list its children
850
+ parentDir = absTarget;
851
+ prefix = "";
852
+ } else {
853
+ parentDir = path.dirname(absTarget);
854
+ prefix = path.basename(absTarget).toLowerCase();
855
+ }
856
+ } catch (e) {
857
+ // Path doesn't exist -- list parent and filter by typed prefix
858
+ parentDir = path.dirname(absTarget);
859
+ prefix = path.basename(absTarget).toLowerCase();
860
+ }
861
+ try {
862
+ var dirItems = fs.readdirSync(parentDir, { withFileTypes: true });
863
+ var dirEntries = [];
864
+ for (var di = 0; di < dirItems.length; di++) {
865
+ var d = dirItems[di];
866
+ if (!d.isDirectory()) continue;
867
+ if (d.name.charAt(0) === ".") continue;
868
+ if (IGNORED_DIRS.has(d.name)) continue;
869
+ if (prefix && !d.name.toLowerCase().startsWith(prefix)) continue;
870
+ dirEntries.push({ name: d.name, path: path.join(parentDir, d.name) });
871
+ }
872
+ dirEntries.sort(function (a, b) { return a.name.localeCompare(b.name); });
873
+ sendTo(ws, { type: "browse_dir_result", path: msg.path, entries: dirEntries });
874
+ } catch (e) {
875
+ sendTo(ws, { type: "browse_dir_result", path: msg.path, entries: [], error: e.message });
876
+ }
877
+ return true;
878
+ }
879
+
880
+ // --- Add project from web UI ---
881
+ if (msg.type === "add_project") {
882
+ var addPath = (msg.path || "").replace(/^~/, require("./config").REAL_HOME);
883
+ var addAbs = path.resolve(addPath);
884
+ try {
885
+ var addStat = fs.statSync(addAbs);
886
+ if (!addStat.isDirectory()) {
887
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Not a directory" });
888
+ return true;
889
+ }
890
+ } catch (e) {
891
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Directory not found" });
892
+ return true;
893
+ }
894
+ if (typeof opts.onAddProject === "function") {
895
+ var result = opts.onAddProject(addAbs, ws._clayUser);
896
+ sendTo(ws, { type: "add_project_result", ok: result.ok, slug: result.slug, error: result.error, existing: result.existing });
897
+ } else {
898
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
899
+ }
900
+ return true;
901
+ }
902
+
903
+ // --- Create new empty project ---
904
+ if (msg.type === "create_project" || msg.type === "clone_project") {
905
+ if (ws._clayUser) {
906
+ var cpPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
907
+ if (!cpPerms.createProject) {
908
+ sendTo(ws, { type: "add_project_result", ok: false, error: "You do not have permission to create projects" });
909
+ return true;
910
+ }
911
+ }
912
+ }
913
+ if (msg.type === "create_project") {
914
+ var createName = (msg.name || "").trim();
915
+ if (!createName || !/^[a-zA-Z0-9_-]+$/.test(createName)) {
916
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Invalid name. Use only letters, numbers, dashes, and underscores." });
917
+ return true;
918
+ }
919
+ if (typeof opts.onCreateProject === "function") {
920
+ var createResult = opts.onCreateProject(createName, ws._clayUser);
921
+ sendTo(ws, { type: "add_project_result", ok: createResult.ok, slug: createResult.slug, error: createResult.error });
922
+ } else {
923
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
924
+ }
925
+ return true;
926
+ }
927
+
928
+ // --- Clone project from GitHub ---
929
+ if (msg.type === "clone_project") {
930
+ var cloneUrl = (msg.url || "").trim();
931
+ if (!cloneUrl || (!/^https?:\/\//.test(cloneUrl) && !/^git@/.test(cloneUrl))) {
932
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Invalid URL. Use https:// or git@ format." });
933
+ return true;
934
+ }
935
+ sendTo(ws, { type: "clone_project_progress", status: "cloning" });
936
+ if (typeof opts.onCloneProject === "function") {
937
+ opts.onCloneProject(cloneUrl, ws._clayUser, function (cloneResult) {
938
+ sendTo(ws, { type: "add_project_result", ok: cloneResult.ok, slug: cloneResult.slug, error: cloneResult.error });
939
+ });
940
+ } else {
941
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
942
+ }
943
+ return true;
944
+ }
945
+
946
+ // --- Create worktree from web UI ---
947
+ if (msg.type === "create_worktree") {
948
+ var wtBranch = (msg.branch || "").trim();
949
+ var wtDirName = (msg.dirName || "").trim() || wtBranch.replace(/\//g, "-");
950
+ var wtBase = (msg.baseBranch || "").trim() || null;
951
+ if (!wtBranch || !/^[a-zA-Z0-9_\/.@-]+$/.test(wtBranch)) {
952
+ sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid branch name" });
953
+ return true;
954
+ }
955
+ if (typeof onCreateWorktree === "function") {
956
+ var wtResult = onCreateWorktree(slug, wtBranch, wtDirName, wtBase);
957
+ sendTo(ws, { type: "create_worktree_result", ok: wtResult.ok, slug: wtResult.slug, error: wtResult.error });
958
+ } else {
959
+ sendTo(ws, { type: "create_worktree_result", ok: false, error: "Not supported" });
960
+ }
961
+ return true;
962
+ }
963
+
964
+ // --- Pre-check: does the project have tasks/schedules? ---
965
+ if (msg.type === "remove_project_check") {
966
+ var checkSlug = msg.slug;
967
+ if (!checkSlug) {
968
+ sendTo(ws, { type: "remove_project_check_result", slug: checkSlug, name: msg.name || checkSlug, count: 0 });
969
+ return true;
970
+ }
971
+ var schedCount = getScheduleCount(checkSlug);
972
+ sendTo(ws, { type: "remove_project_check_result", slug: checkSlug, name: msg.name || checkSlug, count: schedCount });
973
+ return true;
974
+ }
975
+
976
+ // --- Remove project from web UI ---
977
+ if (msg.type === "remove_project") {
978
+ if (ws._clayUser) {
979
+ var dpPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
980
+ if (!dpPerms.deleteProject) {
981
+ sendTo(ws, { type: "remove_project_result", ok: false, error: "You do not have permission to delete projects" });
982
+ return true;
983
+ }
984
+ }
985
+ var removeSlug = msg.slug;
986
+ if (!removeSlug) {
987
+ sendTo(ws, { type: "remove_project_result", ok: false, error: "Missing slug" });
988
+ return true;
989
+ }
990
+ // If client chose to move tasks to another project before removing
991
+ if (msg.moveTasksTo) {
992
+ moveAllSchedulesToProject(removeSlug, msg.moveTasksTo);
993
+ }
994
+ if (typeof opts.onRemoveProject === "function") {
995
+ // Send result before removing so the WS is still open
996
+ sendTo(ws, { type: "remove_project_result", ok: true, slug: removeSlug });
997
+ var removeUserId = ws._clayUser ? ws._clayUser.id : null;
998
+ opts.onRemoveProject(removeSlug, removeUserId);
999
+ } else {
1000
+ sendTo(ws, { type: "remove_project_result", ok: false, error: "Not supported" });
1001
+ }
1002
+ return true;
1003
+ }
1004
+
1005
+ // --- Move a single schedule to another project ---
1006
+ if (msg.type === "schedule_move") {
1007
+ var moveResult = moveScheduleToProject(msg.recordId, msg.fromSlug, msg.toSlug);
1008
+ if (moveResult.ok) {
1009
+ // Re-broadcast updated records to this project's clients
1010
+ send({ type: "loop_registry_updated", records: getHubSchedules() });
1011
+ }
1012
+ sendTo(ws, { type: "schedule_move_result", ok: moveResult.ok, error: moveResult.error });
1013
+ return true;
1014
+ }
1015
+
1016
+ // --- Reorder projects ---
1017
+ if (msg.type === "reorder_projects") {
1018
+ var slugs = msg.slugs;
1019
+ if (!Array.isArray(slugs) || slugs.length === 0) {
1020
+ sendTo(ws, { type: "reorder_projects_result", ok: false, error: "Missing slugs" });
1021
+ return true;
1022
+ }
1023
+ if (typeof opts.onReorderProjects === "function") {
1024
+ var reorderResult = opts.onReorderProjects(slugs);
1025
+ sendTo(ws, { type: "reorder_projects_result", ok: reorderResult.ok, error: reorderResult.error });
1026
+ } else {
1027
+ sendTo(ws, { type: "reorder_projects_result", ok: false, error: "Not supported" });
1028
+ }
1029
+ return true;
1030
+ }
1031
+
1032
+ // --- Set project title (rename) ---
1033
+ if (msg.type === "set_project_title") {
1034
+ if (!msg.slug) {
1035
+ sendTo(ws, { type: "set_project_title_result", ok: false, error: "Missing slug" });
1036
+ return true;
1037
+ }
1038
+ if (typeof opts.onSetProjectTitle === "function") {
1039
+ var titleResult = opts.onSetProjectTitle(msg.slug, msg.title || null);
1040
+ sendTo(ws, { type: "set_project_title_result", ok: titleResult.ok, slug: msg.slug, error: titleResult.error });
1041
+ } else {
1042
+ sendTo(ws, { type: "set_project_title_result", ok: false, error: "Not supported" });
1043
+ }
1044
+ return true;
1045
+ }
1046
+
1047
+ // --- Set project icon (emoji) ---
1048
+ if (msg.type === "set_project_icon") {
1049
+ if (!msg.slug) {
1050
+ sendTo(ws, { type: "set_project_icon_result", ok: false, error: "Missing slug" });
1051
+ return true;
1052
+ }
1053
+ if (typeof opts.onSetProjectIcon === "function") {
1054
+ var iconResult = opts.onSetProjectIcon(msg.slug, msg.icon || null);
1055
+ sendTo(ws, { type: "set_project_icon_result", ok: iconResult.ok, slug: msg.slug, error: iconResult.error });
1056
+ } else {
1057
+ sendTo(ws, { type: "set_project_icon_result", ok: false, error: "Not supported" });
1058
+ }
1059
+ return true;
1060
+ }
1061
+
1062
+ // --- Daemon config / server management (admin-only in multi-user mode) ---
1063
+ if (msg.type === "get_daemon_config" || msg.type === "set_pin" || msg.type === "set_keep_awake" ||
1064
+ msg.type === "set_auto_continue" || msg.type === "set_image_retention" || msg.type === "shutdown_server" || msg.type === "restart_server") {
1065
+ if (usersModule.isMultiUser()) {
1066
+ var _wsUser = ws._clayUser;
1067
+ if (!_wsUser || _wsUser.role !== "admin") {
1068
+ sendTo(ws, { type: "error", message: "Admin access required" });
1069
+ return true;
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ if (msg.type === "get_daemon_config") {
1075
+ if (typeof opts.onGetDaemonConfig === "function") {
1076
+ var daemonConfig = opts.onGetDaemonConfig();
1077
+ sendTo(ws, { type: "daemon_config", config: daemonConfig });
1078
+ }
1079
+ return true;
1080
+ }
1081
+
1082
+ if (msg.type === "set_pin") {
1083
+ if (typeof opts.onSetPin === "function") {
1084
+ var pinResult = opts.onSetPin(msg.pin || null);
1085
+ sendTo(ws, { type: "set_pin_result", ok: pinResult.ok, pinEnabled: pinResult.pinEnabled });
1086
+ }
1087
+ return true;
1088
+ }
1089
+
1090
+ if (msg.type === "set_keep_awake") {
1091
+ if (typeof opts.onSetKeepAwake === "function") {
1092
+ var kaResult = opts.onSetKeepAwake(msg.value);
1093
+ sendTo(ws, { type: "set_keep_awake_result", ok: kaResult.ok, keepAwake: kaResult.keepAwake });
1094
+ send({ type: "keep_awake_changed", keepAwake: kaResult.keepAwake });
1095
+ }
1096
+ return true;
1097
+ }
1098
+
1099
+ if (msg.type === "set_auto_continue") {
1100
+ if (typeof opts.onSetAutoContinue === "function") {
1101
+ var acResult = opts.onSetAutoContinue(msg.value);
1102
+ sendTo(ws, { type: "set_auto_continue_result", ok: acResult.ok, autoContinueOnRateLimit: acResult.autoContinueOnRateLimit });
1103
+ send({ type: "auto_continue_changed", autoContinueOnRateLimit: acResult.autoContinueOnRateLimit });
1104
+ }
1105
+ return true;
1106
+ }
1107
+
1108
+ if (msg.type === "set_image_retention") {
1109
+ if (typeof opts.onSetImageRetention === "function") {
1110
+ var irResult = opts.onSetImageRetention(msg.days);
1111
+ sendTo(ws, { type: "set_image_retention_result", ok: irResult.ok, days: irResult.days });
1112
+ }
1113
+ return true;
1114
+ }
1115
+
1116
+ if (msg.type === "shutdown_server") {
1117
+ if (typeof opts.onShutdown === "function") {
1118
+ sendTo(ws, { type: "shutdown_server_result", ok: true });
1119
+ send({ type: "toast", level: "warn", message: "Server is shutting down..." });
1120
+ // Small delay so the response has time to reach clients
1121
+ setTimeout(function () {
1122
+ opts.onShutdown();
1123
+ }, 500);
1124
+ } else {
1125
+ sendTo(ws, { type: "shutdown_server_result", ok: false, error: "Shutdown not supported" });
1126
+ }
1127
+ return true;
1128
+ }
1129
+
1130
+ if (msg.type === "restart_server") {
1131
+ if (typeof opts.onRestart === "function") {
1132
+ sendTo(ws, { type: "restart_server_result", ok: true });
1133
+ send({ type: "toast", level: "info", message: "Server is restarting..." });
1134
+ // Small delay so the response has time to reach clients
1135
+ setTimeout(function () {
1136
+ opts.onRestart();
1137
+ }, 500);
1138
+ } else {
1139
+ sendTo(ws, { type: "restart_server_result", ok: false, error: "Restart not supported" });
1140
+ }
1141
+ return true;
1142
+ }
1143
+
1144
+ return false;
1145
+ }
1146
+
1147
+ return {
1148
+ handleSessionsMessage: handleSessionsMessage,
1149
+ };
1150
+ }
1151
+
1152
+ module.exports = { attachSessions: attachSessions };