clay-server 2.5.0
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/LICENSE +21 -0
- package/README.md +281 -0
- package/bin/cli.js +2385 -0
- package/lib/cli-sessions.js +270 -0
- package/lib/config.js +237 -0
- package/lib/daemon.js +489 -0
- package/lib/ipc.js +112 -0
- package/lib/notes.js +120 -0
- package/lib/pages.js +664 -0
- package/lib/project.js +1433 -0
- package/lib/public/app.js +2795 -0
- package/lib/public/apple-touch-icon-dark.png +0 -0
- package/lib/public/apple-touch-icon.png +0 -0
- package/lib/public/css/base.css +264 -0
- package/lib/public/css/diff.css +128 -0
- package/lib/public/css/filebrowser.css +1114 -0
- package/lib/public/css/highlight.css +144 -0
- package/lib/public/css/icon-strip.css +296 -0
- package/lib/public/css/input.css +573 -0
- package/lib/public/css/menus.css +856 -0
- package/lib/public/css/messages.css +1445 -0
- package/lib/public/css/mobile-nav.css +354 -0
- package/lib/public/css/overlays.css +697 -0
- package/lib/public/css/rewind.css +505 -0
- package/lib/public/css/server-settings.css +761 -0
- package/lib/public/css/sidebar.css +936 -0
- package/lib/public/css/sticky-notes.css +358 -0
- package/lib/public/css/title-bar.css +314 -0
- package/lib/public/favicon-dark.svg +1 -0
- package/lib/public/favicon.svg +1 -0
- package/lib/public/icon-192-dark.png +0 -0
- package/lib/public/icon-192.png +0 -0
- package/lib/public/icon-512-dark.png +0 -0
- package/lib/public/icon-512.png +0 -0
- package/lib/public/icon-mono.svg +1 -0
- package/lib/public/index.html +762 -0
- package/lib/public/manifest.json +27 -0
- package/lib/public/modules/diff.js +398 -0
- package/lib/public/modules/events.js +21 -0
- package/lib/public/modules/filebrowser.js +1411 -0
- package/lib/public/modules/fileicons.js +172 -0
- package/lib/public/modules/icons.js +54 -0
- package/lib/public/modules/input.js +584 -0
- package/lib/public/modules/markdown.js +356 -0
- package/lib/public/modules/notifications.js +649 -0
- package/lib/public/modules/qrcode.js +70 -0
- package/lib/public/modules/rewind.js +345 -0
- package/lib/public/modules/server-settings.js +510 -0
- package/lib/public/modules/sidebar.js +1083 -0
- package/lib/public/modules/state.js +3 -0
- package/lib/public/modules/sticky-notes.js +688 -0
- package/lib/public/modules/terminal.js +697 -0
- package/lib/public/modules/theme.js +738 -0
- package/lib/public/modules/tools.js +1608 -0
- package/lib/public/modules/utils.js +56 -0
- package/lib/public/style.css +15 -0
- package/lib/public/sw.js +75 -0
- package/lib/push.js +124 -0
- package/lib/sdk-bridge.js +989 -0
- package/lib/server.js +582 -0
- package/lib/sessions.js +424 -0
- package/lib/terminal-manager.js +187 -0
- package/lib/terminal.js +24 -0
- package/lib/themes/ayu-light.json +9 -0
- package/lib/themes/catppuccin-latte.json +9 -0
- package/lib/themes/catppuccin-mocha.json +9 -0
- package/lib/themes/clay-light.json +10 -0
- package/lib/themes/clay.json +10 -0
- package/lib/themes/dracula.json +9 -0
- package/lib/themes/everforest-light.json +9 -0
- package/lib/themes/everforest.json +9 -0
- package/lib/themes/github-light.json +9 -0
- package/lib/themes/gruvbox-dark.json +9 -0
- package/lib/themes/gruvbox-light.json +9 -0
- package/lib/themes/monokai.json +9 -0
- package/lib/themes/nord-light.json +9 -0
- package/lib/themes/nord.json +9 -0
- package/lib/themes/one-dark.json +9 -0
- package/lib/themes/one-light.json +9 -0
- package/lib/themes/rose-pine-dawn.json +9 -0
- package/lib/themes/rose-pine.json +9 -0
- package/lib/themes/solarized-dark.json +9 -0
- package/lib/themes/solarized-light.json +9 -0
- package/lib/themes/tokyo-night-light.json +9 -0
- package/lib/themes/tokyo-night.json +9 -0
- package/lib/updater.js +97 -0
- package/package.json +47 -0
package/lib/project.js
ADDED
|
@@ -0,0 +1,1433 @@
|
|
|
1
|
+
var fs = require("fs");
|
|
2
|
+
var path = require("path");
|
|
3
|
+
var { createSessionManager } = require("./sessions");
|
|
4
|
+
var { createSDKBridge } = require("./sdk-bridge");
|
|
5
|
+
var { createTerminalManager } = require("./terminal-manager");
|
|
6
|
+
var { createNotesManager } = require("./notes");
|
|
7
|
+
var { fetchLatestVersion, isNewer } = require("./updater");
|
|
8
|
+
var { execFileSync } = require("child_process");
|
|
9
|
+
|
|
10
|
+
// SDK loaded dynamically (ESM module)
|
|
11
|
+
var sdkModule = null;
|
|
12
|
+
function getSDK() {
|
|
13
|
+
if (!sdkModule) sdkModule = import("@anthropic-ai/claude-agent-sdk");
|
|
14
|
+
return sdkModule;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// --- Shared constants ---
|
|
18
|
+
var IGNORED_DIRS = new Set(["node_modules", ".git", ".next", "__pycache__", ".cache", "dist", "build", ".clay", ".claude-relay"]);
|
|
19
|
+
var BINARY_EXTS = new Set([
|
|
20
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp",
|
|
21
|
+
".woff", ".woff2", ".ttf", ".eot", ".otf",
|
|
22
|
+
".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
|
|
23
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
|
24
|
+
".exe", ".dll", ".so", ".dylib",
|
|
25
|
+
".mp3", ".mp4", ".wav", ".avi", ".mov",
|
|
26
|
+
".pyc", ".o", ".a", ".class",
|
|
27
|
+
]);
|
|
28
|
+
var IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]);
|
|
29
|
+
var FS_MAX_SIZE = 512 * 1024;
|
|
30
|
+
var MIME_TYPES = {
|
|
31
|
+
".html": "text/html",
|
|
32
|
+
".css": "text/css",
|
|
33
|
+
".js": "application/javascript",
|
|
34
|
+
".json": "application/json",
|
|
35
|
+
".png": "image/png",
|
|
36
|
+
".jpg": "image/jpeg",
|
|
37
|
+
".jpeg": "image/jpeg",
|
|
38
|
+
".gif": "image/gif",
|
|
39
|
+
".webp": "image/webp",
|
|
40
|
+
".bmp": "image/bmp",
|
|
41
|
+
".svg": "image/svg+xml",
|
|
42
|
+
".ico": "image/x-icon",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function safePath(base, requested) {
|
|
46
|
+
var resolved = path.resolve(base, requested);
|
|
47
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep)) return null;
|
|
48
|
+
try {
|
|
49
|
+
var real = fs.realpathSync(resolved);
|
|
50
|
+
if (real !== base && !real.startsWith(base + path.sep)) return null;
|
|
51
|
+
return real;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a project context — per-project state and handlers.
|
|
59
|
+
* opts: { cwd, slug, title, pushModule, debug, dangerouslySkipPermissions, currentVersion }
|
|
60
|
+
*/
|
|
61
|
+
function createProjectContext(opts) {
|
|
62
|
+
var cwd = opts.cwd;
|
|
63
|
+
var slug = opts.slug;
|
|
64
|
+
var project = path.basename(cwd);
|
|
65
|
+
var title = opts.title || null;
|
|
66
|
+
var pushModule = opts.pushModule || null;
|
|
67
|
+
var debug = opts.debug || false;
|
|
68
|
+
var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
|
|
69
|
+
var currentVersion = opts.currentVersion;
|
|
70
|
+
var lanHost = opts.lanHost || null;
|
|
71
|
+
var getProjectCount = opts.getProjectCount || function () { return 1; };
|
|
72
|
+
var getProjectList = opts.getProjectList || function () { return []; };
|
|
73
|
+
var latestVersion = null;
|
|
74
|
+
|
|
75
|
+
// --- Per-project clients ---
|
|
76
|
+
var clients = new Set();
|
|
77
|
+
|
|
78
|
+
function send(obj) {
|
|
79
|
+
var data = JSON.stringify(obj);
|
|
80
|
+
for (var ws of clients) {
|
|
81
|
+
if (ws.readyState === 1) ws.send(data);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sendTo(ws, obj) {
|
|
86
|
+
if (ws.readyState === 1) ws.send(JSON.stringify(obj));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function broadcastClientCount() {
|
|
90
|
+
send({ type: "client_count", count: clients.size });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sendToOthers(sender, obj) {
|
|
94
|
+
var data = JSON.stringify(obj);
|
|
95
|
+
for (var ws of clients) {
|
|
96
|
+
if (ws !== sender && ws.readyState === 1) ws.send(data);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- File watcher ---
|
|
101
|
+
var fileWatcher = null;
|
|
102
|
+
var watchedPath = null;
|
|
103
|
+
var watchDebounce = null;
|
|
104
|
+
|
|
105
|
+
function startFileWatch(relPath) {
|
|
106
|
+
var absPath = safePath(cwd, relPath);
|
|
107
|
+
if (!absPath) return;
|
|
108
|
+
if (watchedPath === relPath) return;
|
|
109
|
+
stopFileWatch();
|
|
110
|
+
watchedPath = relPath;
|
|
111
|
+
try {
|
|
112
|
+
fileWatcher = fs.watch(absPath, function () {
|
|
113
|
+
clearTimeout(watchDebounce);
|
|
114
|
+
watchDebounce = setTimeout(function () {
|
|
115
|
+
try {
|
|
116
|
+
var stat = fs.statSync(absPath);
|
|
117
|
+
var ext = path.extname(absPath).toLowerCase();
|
|
118
|
+
if (stat.size > FS_MAX_SIZE || BINARY_EXTS.has(ext)) return;
|
|
119
|
+
var content = fs.readFileSync(absPath, "utf8");
|
|
120
|
+
send({ type: "fs_file_changed", path: relPath, content: content, size: stat.size });
|
|
121
|
+
} catch (e) {
|
|
122
|
+
stopFileWatch();
|
|
123
|
+
}
|
|
124
|
+
}, 200);
|
|
125
|
+
});
|
|
126
|
+
fileWatcher.on("error", function () { stopFileWatch(); });
|
|
127
|
+
} catch (e) {
|
|
128
|
+
watchedPath = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function stopFileWatch() {
|
|
133
|
+
if (fileWatcher) {
|
|
134
|
+
try { fileWatcher.close(); } catch (e) {}
|
|
135
|
+
fileWatcher = null;
|
|
136
|
+
}
|
|
137
|
+
clearTimeout(watchDebounce);
|
|
138
|
+
watchDebounce = null;
|
|
139
|
+
watchedPath = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Directory watcher ---
|
|
143
|
+
var dirWatchers = {}; // relPath -> { watcher, debounce }
|
|
144
|
+
|
|
145
|
+
function startDirWatch(relPath) {
|
|
146
|
+
if (dirWatchers[relPath]) return;
|
|
147
|
+
var absPath = safePath(cwd, relPath);
|
|
148
|
+
if (!absPath) return;
|
|
149
|
+
try {
|
|
150
|
+
var debounce = null;
|
|
151
|
+
var watcher = fs.watch(absPath, function () {
|
|
152
|
+
clearTimeout(debounce);
|
|
153
|
+
debounce = setTimeout(function () {
|
|
154
|
+
// Re-read directory and broadcast to all clients
|
|
155
|
+
try {
|
|
156
|
+
var items = fs.readdirSync(absPath, { withFileTypes: true });
|
|
157
|
+
var entries = [];
|
|
158
|
+
for (var i = 0; i < items.length; i++) {
|
|
159
|
+
if (items[i].isDirectory() && IGNORED_DIRS.has(items[i].name)) continue;
|
|
160
|
+
entries.push({
|
|
161
|
+
name: items[i].name,
|
|
162
|
+
type: items[i].isDirectory() ? "dir" : "file",
|
|
163
|
+
path: path.relative(cwd, path.join(absPath, items[i].name)).split(path.sep).join("/"),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
send({ type: "fs_dir_changed", path: relPath, entries: entries });
|
|
167
|
+
} catch (e) {
|
|
168
|
+
stopDirWatch(relPath);
|
|
169
|
+
}
|
|
170
|
+
}, 300);
|
|
171
|
+
});
|
|
172
|
+
watcher.on("error", function () { stopDirWatch(relPath); });
|
|
173
|
+
dirWatchers[relPath] = { watcher: watcher, debounce: debounce };
|
|
174
|
+
} catch (e) {}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function stopDirWatch(relPath) {
|
|
178
|
+
var entry = dirWatchers[relPath];
|
|
179
|
+
if (entry) {
|
|
180
|
+
clearTimeout(entry.debounce);
|
|
181
|
+
try { entry.watcher.close(); } catch (e) {}
|
|
182
|
+
delete dirWatchers[relPath];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function stopAllDirWatches() {
|
|
187
|
+
var paths = Object.keys(dirWatchers);
|
|
188
|
+
for (var i = 0; i < paths.length; i++) {
|
|
189
|
+
stopDirWatch(paths[i]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- Session manager ---
|
|
194
|
+
var sm = createSessionManager({ cwd: cwd, send: send });
|
|
195
|
+
sm.currentPermissionMode = "default";
|
|
196
|
+
sm.currentEffort = "high";
|
|
197
|
+
|
|
198
|
+
// --- SDK bridge ---
|
|
199
|
+
var sdk = createSDKBridge({
|
|
200
|
+
cwd: cwd,
|
|
201
|
+
slug: slug,
|
|
202
|
+
sessionManager: sm,
|
|
203
|
+
send: send,
|
|
204
|
+
pushModule: pushModule,
|
|
205
|
+
getSDK: getSDK,
|
|
206
|
+
dangerouslySkipPermissions: dangerouslySkipPermissions,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// --- Terminal manager ---
|
|
210
|
+
var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
|
|
211
|
+
var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
|
|
212
|
+
|
|
213
|
+
// Check for updates in background
|
|
214
|
+
fetchLatestVersion().then(function (v) {
|
|
215
|
+
if (v && isNewer(v, currentVersion)) {
|
|
216
|
+
latestVersion = v;
|
|
217
|
+
send({ type: "update_available", version: v });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// --- WS connection handler ---
|
|
222
|
+
function handleConnection(ws) {
|
|
223
|
+
clients.add(ws);
|
|
224
|
+
broadcastClientCount();
|
|
225
|
+
|
|
226
|
+
// Send cached state
|
|
227
|
+
sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
|
|
228
|
+
if (latestVersion) {
|
|
229
|
+
sendTo(ws, { type: "update_available", version: latestVersion });
|
|
230
|
+
}
|
|
231
|
+
if (sm.slashCommands) {
|
|
232
|
+
sendTo(ws, { type: "slash_commands", commands: sm.slashCommands });
|
|
233
|
+
}
|
|
234
|
+
if (sm.currentModel) {
|
|
235
|
+
sendTo(ws, { type: "model_info", model: sm.currentModel, models: sm.availableModels || [] });
|
|
236
|
+
}
|
|
237
|
+
sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
|
|
238
|
+
sendTo(ws, { type: "term_list", terminals: tm.list() });
|
|
239
|
+
sendTo(ws, { type: "notes_list", notes: nm.list() });
|
|
240
|
+
|
|
241
|
+
// Session list
|
|
242
|
+
sendTo(ws, {
|
|
243
|
+
type: "session_list",
|
|
244
|
+
sessions: [].concat(Array.from(sm.sessions.values())).map(function (s) {
|
|
245
|
+
return {
|
|
246
|
+
id: s.localId,
|
|
247
|
+
cliSessionId: s.cliSessionId || null,
|
|
248
|
+
title: s.title || "New Session",
|
|
249
|
+
active: s.localId === sm.activeSessionId,
|
|
250
|
+
isProcessing: s.isProcessing,
|
|
251
|
+
lastActivity: s.lastActivity || s.createdAt || 0,
|
|
252
|
+
};
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Restore active session for this client
|
|
257
|
+
var active = sm.getActiveSession();
|
|
258
|
+
if (active) {
|
|
259
|
+
sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null });
|
|
260
|
+
|
|
261
|
+
var total = active.history.length;
|
|
262
|
+
var fromIndex = 0;
|
|
263
|
+
if (total > sm.HISTORY_PAGE_SIZE) {
|
|
264
|
+
fromIndex = sm.findTurnBoundary(active.history, Math.max(0, total - sm.HISTORY_PAGE_SIZE));
|
|
265
|
+
}
|
|
266
|
+
sendTo(ws, { type: "history_meta", total: total, from: fromIndex });
|
|
267
|
+
for (var i = fromIndex; i < total; i++) {
|
|
268
|
+
sendTo(ws, active.history[i]);
|
|
269
|
+
}
|
|
270
|
+
sendTo(ws, { type: "history_done" });
|
|
271
|
+
|
|
272
|
+
if (active.isProcessing) {
|
|
273
|
+
sendTo(ws, { type: "status", status: "processing" });
|
|
274
|
+
}
|
|
275
|
+
var pendingIds = Object.keys(active.pendingPermissions);
|
|
276
|
+
for (var pi = 0; pi < pendingIds.length; pi++) {
|
|
277
|
+
var p = active.pendingPermissions[pendingIds[pi]];
|
|
278
|
+
sendTo(ws, {
|
|
279
|
+
type: "permission_request_pending",
|
|
280
|
+
requestId: p.requestId,
|
|
281
|
+
toolName: p.toolName,
|
|
282
|
+
toolInput: p.toolInput,
|
|
283
|
+
toolUseId: p.toolUseId,
|
|
284
|
+
decisionReason: p.decisionReason,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
ws.on("message", function (raw) {
|
|
290
|
+
var msg;
|
|
291
|
+
try { msg = JSON.parse(raw.toString()); } catch (e) { return; }
|
|
292
|
+
handleMessage(ws, msg);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
ws.on("close", function () {
|
|
296
|
+
handleDisconnection(ws);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- WS message handler ---
|
|
301
|
+
function handleMessage(ws, msg) {
|
|
302
|
+
if (msg.type === "push_subscribe") {
|
|
303
|
+
if (pushModule && msg.subscription) pushModule.addSubscription(msg.subscription, msg.replaceEndpoint);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (msg.type === "load_more_history") {
|
|
308
|
+
var session = sm.getActiveSession();
|
|
309
|
+
if (!session || typeof msg.before !== "number") return;
|
|
310
|
+
var before = msg.before;
|
|
311
|
+
var from = sm.findTurnBoundary(session.history, Math.max(0, before - sm.HISTORY_PAGE_SIZE));
|
|
312
|
+
var to = before;
|
|
313
|
+
var items = session.history.slice(from, to);
|
|
314
|
+
sendTo(ws, {
|
|
315
|
+
type: "history_prepend",
|
|
316
|
+
items: items,
|
|
317
|
+
meta: { from: from, to: to, hasMore: from > 0 },
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (msg.type === "new_session") {
|
|
323
|
+
sm.createSession();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (msg.type === "resume_session") {
|
|
328
|
+
if (!msg.cliSessionId) return;
|
|
329
|
+
var cliSess = require("./cli-sessions");
|
|
330
|
+
cliSess.readCliSessionHistory(cwd, msg.cliSessionId).then(function (history) {
|
|
331
|
+
var title = "Resumed session";
|
|
332
|
+
for (var i = 0; i < history.length; i++) {
|
|
333
|
+
if (history[i].type === "user_message" && history[i].text) {
|
|
334
|
+
title = history[i].text.substring(0, 50);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
sm.resumeSession(msg.cliSessionId, { history: history, title: title });
|
|
339
|
+
}).catch(function () {
|
|
340
|
+
sm.resumeSession(msg.cliSessionId);
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (msg.type === "list_cli_sessions") {
|
|
346
|
+
var cliSessions = require("./cli-sessions");
|
|
347
|
+
var _fs = require("fs");
|
|
348
|
+
var _path = require("path");
|
|
349
|
+
// Collect session IDs already in relay (in-memory + persisted on disk)
|
|
350
|
+
var relayIds = {};
|
|
351
|
+
sm.sessions.forEach(function (s) {
|
|
352
|
+
if (s.cliSessionId) relayIds[s.cliSessionId] = true;
|
|
353
|
+
});
|
|
354
|
+
try {
|
|
355
|
+
var sessDir = sm.sessionsDir;
|
|
356
|
+
var diskFiles = _fs.readdirSync(sessDir);
|
|
357
|
+
for (var fi = 0; fi < diskFiles.length; fi++) {
|
|
358
|
+
if (diskFiles[fi].endsWith(".jsonl")) {
|
|
359
|
+
relayIds[diskFiles[fi].replace(".jsonl", "")] = true;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch (e) {}
|
|
363
|
+
cliSessions.listCliSessions(cwd).then(function (sessions) {
|
|
364
|
+
var filtered = sessions.filter(function (s) {
|
|
365
|
+
return !relayIds[s.sessionId];
|
|
366
|
+
});
|
|
367
|
+
sendTo(ws, { type: "cli_session_list", sessions: filtered });
|
|
368
|
+
}).catch(function () {
|
|
369
|
+
sendTo(ws, { type: "cli_session_list", sessions: [] });
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if (msg.type === "switch_session") {
|
|
376
|
+
if (msg.id && sm.sessions.has(msg.id)) {
|
|
377
|
+
sm.switchSession(msg.id);
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (msg.type === "delete_session") {
|
|
383
|
+
if (msg.id && sm.sessions.has(msg.id)) {
|
|
384
|
+
sm.deleteSession(msg.id);
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (msg.type === "rename_session") {
|
|
390
|
+
if (msg.id && sm.sessions.has(msg.id) && msg.title) {
|
|
391
|
+
var s = sm.sessions.get(msg.id);
|
|
392
|
+
s.title = String(msg.title).substring(0, 100);
|
|
393
|
+
sm.saveSessionFile(s);
|
|
394
|
+
sm.broadcastSessionList();
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (msg.type === "search_sessions") {
|
|
400
|
+
var results = sm.searchSessions(msg.query || "");
|
|
401
|
+
sendTo(ws, { type: "search_results", query: msg.query || "", results: results });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (msg.type === "check_update") {
|
|
406
|
+
fetchLatestVersion().then(function (v) {
|
|
407
|
+
if (v && isNewer(v, currentVersion)) {
|
|
408
|
+
latestVersion = v;
|
|
409
|
+
sendTo(ws, { type: "update_available", version: v });
|
|
410
|
+
}
|
|
411
|
+
}).catch(function () {});
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (msg.type === "update_now") {
|
|
416
|
+
send({ type: "update_started", version: latestVersion || "" });
|
|
417
|
+
var _ipc = require("./ipc");
|
|
418
|
+
var _config = require("./config");
|
|
419
|
+
_ipc.sendIPCCommand(_config.socketPath(), { cmd: "update" });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (msg.type === "process_stats") {
|
|
424
|
+
var sessionCount = sm.sessions.size;
|
|
425
|
+
var processingCount = 0;
|
|
426
|
+
sm.sessions.forEach(function (s) {
|
|
427
|
+
if (s.isProcessing) processingCount++;
|
|
428
|
+
});
|
|
429
|
+
var mem = process.memoryUsage();
|
|
430
|
+
sendTo(ws, {
|
|
431
|
+
type: "process_stats",
|
|
432
|
+
pid: process.pid,
|
|
433
|
+
uptime: process.uptime(),
|
|
434
|
+
memory: {
|
|
435
|
+
rss: mem.rss,
|
|
436
|
+
heapUsed: mem.heapUsed,
|
|
437
|
+
heapTotal: mem.heapTotal,
|
|
438
|
+
external: mem.external,
|
|
439
|
+
},
|
|
440
|
+
sessions: sessionCount,
|
|
441
|
+
processing: processingCount,
|
|
442
|
+
clients: clients.size,
|
|
443
|
+
terminals: tm.list().length,
|
|
444
|
+
});
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (msg.type === "stop") {
|
|
449
|
+
var session = sm.getActiveSession();
|
|
450
|
+
if (session && session.abortController && session.isProcessing) {
|
|
451
|
+
session.abortController.abort();
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
if (msg.type === "stop_task") {
|
|
458
|
+
if (msg.taskId) {
|
|
459
|
+
sdk.stopTask(msg.taskId);
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (msg.type === "kill_process") {
|
|
465
|
+
var pid = msg.pid;
|
|
466
|
+
if (!pid || typeof pid !== "number") return;
|
|
467
|
+
// Verify target is actually a claude process before killing
|
|
468
|
+
if (!sdk.isClaudeProcess(pid)) {
|
|
469
|
+
console.error("[project] Refused to kill PID " + pid + ": not a claude process");
|
|
470
|
+
send({ type: "error", text: "Process " + pid + " is not a Claude process." });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
process.kill(pid, "SIGTERM");
|
|
475
|
+
console.log("[project] Sent SIGTERM to conflicting Claude process PID " + pid);
|
|
476
|
+
send({ type: "process_killed", pid: pid });
|
|
477
|
+
} catch (e) {
|
|
478
|
+
console.error("[project] Failed to kill PID " + pid + ":", e.message);
|
|
479
|
+
send({ type: "error", text: "Failed to kill process " + pid + ": " + (e.message || e) });
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (msg.type === "set_model" && msg.model) {
|
|
485
|
+
var session = sm.getActiveSession();
|
|
486
|
+
if (session) {
|
|
487
|
+
sdk.setModel(session, msg.model);
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (msg.type === "set_permission_mode" && msg.mode) {
|
|
493
|
+
sm.currentPermissionMode = msg.mode;
|
|
494
|
+
var session = sm.getActiveSession();
|
|
495
|
+
if (session) {
|
|
496
|
+
sdk.setPermissionMode(session, msg.mode);
|
|
497
|
+
}
|
|
498
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (msg.type === "set_effort" && msg.effort) {
|
|
503
|
+
sm.currentEffort = msg.effort;
|
|
504
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (msg.type === "set_betas") {
|
|
509
|
+
sm.currentBetas = msg.betas || [];
|
|
510
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "high", betas: sm.currentBetas });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (msg.type === "rewind_preview") {
|
|
515
|
+
var session = sm.getActiveSession();
|
|
516
|
+
if (!session || !session.cliSessionId || !msg.uuid) return;
|
|
517
|
+
|
|
518
|
+
(async function () {
|
|
519
|
+
var result;
|
|
520
|
+
try {
|
|
521
|
+
result = await sdk.getOrCreateRewindQuery(session);
|
|
522
|
+
var preview = await result.query.rewindFiles(msg.uuid, { dryRun: true });
|
|
523
|
+
var diffs = {};
|
|
524
|
+
var changedFiles = preview.filesChanged || [];
|
|
525
|
+
for (var f = 0; f < changedFiles.length; f++) {
|
|
526
|
+
try {
|
|
527
|
+
diffs[changedFiles[f]] = execFileSync(
|
|
528
|
+
"git", ["diff", "HEAD", "--", changedFiles[f]],
|
|
529
|
+
{ cwd: cwd, encoding: "utf8", timeout: 5000 }
|
|
530
|
+
) || "";
|
|
531
|
+
} catch (e) { diffs[changedFiles[f]] = ""; }
|
|
532
|
+
}
|
|
533
|
+
sendTo(ws, { type: "rewind_preview_result", preview: preview, diffs: diffs, uuid: msg.uuid });
|
|
534
|
+
} catch (err) {
|
|
535
|
+
sendTo(ws, { type: "rewind_error", text: "Failed to preview rewind: " + err.message });
|
|
536
|
+
} finally {
|
|
537
|
+
if (result && result.isTemp) result.cleanup();
|
|
538
|
+
}
|
|
539
|
+
})();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (msg.type === "rewind_execute") {
|
|
544
|
+
var session = sm.getActiveSession();
|
|
545
|
+
if (!session || !session.cliSessionId || !msg.uuid) return;
|
|
546
|
+
var mode = msg.mode || "both";
|
|
547
|
+
|
|
548
|
+
(async function () {
|
|
549
|
+
var result;
|
|
550
|
+
try {
|
|
551
|
+
// File restoration (skip for chat-only mode)
|
|
552
|
+
if (mode !== "chat") {
|
|
553
|
+
result = await sdk.getOrCreateRewindQuery(session);
|
|
554
|
+
await result.query.rewindFiles(msg.uuid, { dryRun: false });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Conversation rollback (skip for files-only mode)
|
|
558
|
+
if (mode !== "files") {
|
|
559
|
+
var targetIdx = -1;
|
|
560
|
+
for (var i = 0; i < session.messageUUIDs.length; i++) {
|
|
561
|
+
if (session.messageUUIDs[i].uuid === msg.uuid) {
|
|
562
|
+
targetIdx = i;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (targetIdx >= 0) {
|
|
568
|
+
var trimTo = session.messageUUIDs[targetIdx].historyIndex;
|
|
569
|
+
for (var k = trimTo - 1; k >= 0; k--) {
|
|
570
|
+
if (session.history[k].type === "user_message") {
|
|
571
|
+
trimTo = k;
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
session.history = session.history.slice(0, trimTo);
|
|
576
|
+
session.messageUUIDs = session.messageUUIDs.slice(0, targetIdx);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
session.lastRewindUuid = msg.uuid;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (session.abortController) {
|
|
583
|
+
try { session.abortController.abort(); } catch (e) {}
|
|
584
|
+
}
|
|
585
|
+
if (session.messageQueue) {
|
|
586
|
+
try { session.messageQueue.end(); } catch (e) {}
|
|
587
|
+
}
|
|
588
|
+
session.queryInstance = null;
|
|
589
|
+
session.messageQueue = null;
|
|
590
|
+
session.abortController = null;
|
|
591
|
+
session.blocks = {};
|
|
592
|
+
session.sentToolResults = {};
|
|
593
|
+
session.pendingPermissions = {};
|
|
594
|
+
session.pendingAskUser = {};
|
|
595
|
+
session.isProcessing = false;
|
|
596
|
+
|
|
597
|
+
sm.saveSessionFile(session);
|
|
598
|
+
sm.switchSession(session.localId);
|
|
599
|
+
sm.sendAndRecord(session, { type: "rewind_complete", mode: mode });
|
|
600
|
+
sm.broadcastSessionList();
|
|
601
|
+
} catch (err) {
|
|
602
|
+
send({ type: "rewind_error", text: "Rewind failed: " + err.message });
|
|
603
|
+
} finally {
|
|
604
|
+
if (result && result.isTemp) result.cleanup();
|
|
605
|
+
}
|
|
606
|
+
})();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (msg.type === "ask_user_response") {
|
|
611
|
+
var session = sm.getActiveSession();
|
|
612
|
+
if (!session) return;
|
|
613
|
+
var toolId = msg.toolId;
|
|
614
|
+
var answers = msg.answers || {};
|
|
615
|
+
var pending = session.pendingAskUser[toolId];
|
|
616
|
+
if (!pending) return;
|
|
617
|
+
delete session.pendingAskUser[toolId];
|
|
618
|
+
sm.sendAndRecord(session, { type: "ask_user_answered", toolId: toolId });
|
|
619
|
+
pending.resolve({
|
|
620
|
+
behavior: "allow",
|
|
621
|
+
updatedInput: Object.assign({}, pending.input, { answers: answers }),
|
|
622
|
+
});
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (msg.type === "input_sync") {
|
|
627
|
+
sendToOthers(ws, msg);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (msg.type === "permission_response") {
|
|
632
|
+
var session = sm.getActiveSession();
|
|
633
|
+
if (!session) return;
|
|
634
|
+
var requestId = msg.requestId;
|
|
635
|
+
var decision = msg.decision;
|
|
636
|
+
var pending = session.pendingPermissions[requestId];
|
|
637
|
+
if (!pending) return;
|
|
638
|
+
delete session.pendingPermissions[requestId];
|
|
639
|
+
|
|
640
|
+
// --- Plan approval: "allow_accept_edits" — approve + switch to acceptEdits mode ---
|
|
641
|
+
if (decision === "allow_accept_edits") {
|
|
642
|
+
sdk.setPermissionMode(session, "acceptEdits");
|
|
643
|
+
sm.currentPermissionMode = "acceptEdits";
|
|
644
|
+
send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
|
|
645
|
+
pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
|
|
646
|
+
sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// --- Plan approval: "allow_clear_context" — new session + plan as first message + acceptEdits ---
|
|
651
|
+
if (decision === "allow_clear_context") {
|
|
652
|
+
// Deny current plan to end the turn
|
|
653
|
+
pending.resolve({ behavior: "deny", message: "User chose to clear context and restart" });
|
|
654
|
+
sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
|
|
655
|
+
|
|
656
|
+
// Abort the old session's query so it stops processing immediately
|
|
657
|
+
if (session.abortController) {
|
|
658
|
+
session.abortController.abort();
|
|
659
|
+
}
|
|
660
|
+
session.isProcessing = false;
|
|
661
|
+
session.pendingPermissions = {};
|
|
662
|
+
session.pendingAskUser = {};
|
|
663
|
+
sm.broadcastSessionList();
|
|
664
|
+
|
|
665
|
+
// Build prompt from plan content (sent from client) or plan file path
|
|
666
|
+
var clientPlanContent = msg.planContent || "";
|
|
667
|
+
var planPrompt;
|
|
668
|
+
if (clientPlanContent) {
|
|
669
|
+
planPrompt = "Execute the following plan. Do NOT re-enter plan mode — just implement it step by step.\n\n" + clientPlanContent;
|
|
670
|
+
} else {
|
|
671
|
+
var planFilePath = (pending.toolInput && pending.toolInput.planFilePath) || "";
|
|
672
|
+
planPrompt = "Execute the plan in " + planFilePath + ". Do NOT re-enter plan mode — read the plan file and implement it step by step.";
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Wait a tick for the deny to propagate, then create new session + send plan
|
|
676
|
+
setTimeout(function () {
|
|
677
|
+
var newSession = sm.createSession();
|
|
678
|
+
// Send the plan as the first user message (with planContent for UI rendering)
|
|
679
|
+
var userMsg = { type: "user_message", text: planPrompt, planContent: clientPlanContent || null };
|
|
680
|
+
newSession.history.push(userMsg);
|
|
681
|
+
sm.appendToSessionFile(newSession, userMsg);
|
|
682
|
+
newSession.title = "Plan execution (cleared context)";
|
|
683
|
+
sm.saveSessionFile(newSession);
|
|
684
|
+
sm.broadcastSessionList();
|
|
685
|
+
send(userMsg);
|
|
686
|
+
|
|
687
|
+
newSession.isProcessing = true;
|
|
688
|
+
newSession.sentToolResults = {};
|
|
689
|
+
send({ type: "status", status: "processing" });
|
|
690
|
+
newSession.acceptEditsAfterStart = true;
|
|
691
|
+
sdk.startQuery(newSession, planPrompt);
|
|
692
|
+
}, 200);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// --- Plan approval: "deny_with_feedback" — deny + send feedback as follow-up message ---
|
|
697
|
+
if (decision === "deny_with_feedback") {
|
|
698
|
+
var feedback = msg.feedback || "";
|
|
699
|
+
pending.resolve({ behavior: "deny", message: feedback || "User provided feedback" });
|
|
700
|
+
sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
|
|
701
|
+
|
|
702
|
+
// Send feedback as next user message if there's text
|
|
703
|
+
if (feedback) {
|
|
704
|
+
setTimeout(function () {
|
|
705
|
+
var userMsg = { type: "user_message", text: feedback };
|
|
706
|
+
session.history.push(userMsg);
|
|
707
|
+
sm.appendToSessionFile(session, userMsg);
|
|
708
|
+
send(userMsg);
|
|
709
|
+
|
|
710
|
+
if (!session.isProcessing) {
|
|
711
|
+
session.isProcessing = true;
|
|
712
|
+
session.sentToolResults = {};
|
|
713
|
+
send({ type: "status", status: "processing" });
|
|
714
|
+
if (!session.queryInstance) {
|
|
715
|
+
sdk.startQuery(session, feedback);
|
|
716
|
+
} else {
|
|
717
|
+
sdk.pushMessage(session, feedback);
|
|
718
|
+
}
|
|
719
|
+
} else {
|
|
720
|
+
sdk.pushMessage(session, feedback);
|
|
721
|
+
}
|
|
722
|
+
}, 200);
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (decision === "allow" || decision === "allow_always") {
|
|
728
|
+
if (decision === "allow_always") {
|
|
729
|
+
if (!session.allowedTools) session.allowedTools = {};
|
|
730
|
+
session.allowedTools[pending.toolName] = true;
|
|
731
|
+
}
|
|
732
|
+
pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
|
|
733
|
+
} else {
|
|
734
|
+
pending.resolve({ behavior: "deny", message: "User denied permission" });
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
sm.sendAndRecord(session, {
|
|
738
|
+
type: "permission_resolved",
|
|
739
|
+
requestId: requestId,
|
|
740
|
+
decision: decision,
|
|
741
|
+
});
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// --- Browse directories (for add-project autocomplete) ---
|
|
746
|
+
if (msg.type === "browse_dir") {
|
|
747
|
+
var rawPath = (msg.path || "").replace(/^~/, process.env.HOME || "/");
|
|
748
|
+
var absTarget = path.resolve(rawPath);
|
|
749
|
+
var parentDir, prefix;
|
|
750
|
+
try {
|
|
751
|
+
var stat = fs.statSync(absTarget);
|
|
752
|
+
if (stat.isDirectory()) {
|
|
753
|
+
// Input is an existing directory — list its children
|
|
754
|
+
parentDir = absTarget;
|
|
755
|
+
prefix = "";
|
|
756
|
+
} else {
|
|
757
|
+
parentDir = path.dirname(absTarget);
|
|
758
|
+
prefix = path.basename(absTarget).toLowerCase();
|
|
759
|
+
}
|
|
760
|
+
} catch (e) {
|
|
761
|
+
// Path doesn't exist — list parent and filter by typed prefix
|
|
762
|
+
parentDir = path.dirname(absTarget);
|
|
763
|
+
prefix = path.basename(absTarget).toLowerCase();
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
var dirItems = fs.readdirSync(parentDir, { withFileTypes: true });
|
|
767
|
+
var dirEntries = [];
|
|
768
|
+
for (var di = 0; di < dirItems.length; di++) {
|
|
769
|
+
var d = dirItems[di];
|
|
770
|
+
if (!d.isDirectory()) continue;
|
|
771
|
+
if (d.name.charAt(0) === ".") continue;
|
|
772
|
+
if (IGNORED_DIRS.has(d.name)) continue;
|
|
773
|
+
if (prefix && !d.name.toLowerCase().startsWith(prefix)) continue;
|
|
774
|
+
dirEntries.push({ name: d.name, path: path.join(parentDir, d.name) });
|
|
775
|
+
}
|
|
776
|
+
dirEntries.sort(function (a, b) { return a.name.localeCompare(b.name); });
|
|
777
|
+
sendTo(ws, { type: "browse_dir_result", path: msg.path, entries: dirEntries });
|
|
778
|
+
} catch (e) {
|
|
779
|
+
sendTo(ws, { type: "browse_dir_result", path: msg.path, entries: [], error: e.message });
|
|
780
|
+
}
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// --- Add project from web UI ---
|
|
785
|
+
if (msg.type === "add_project") {
|
|
786
|
+
var addPath = (msg.path || "").replace(/^~/, process.env.HOME || "/");
|
|
787
|
+
var addAbs = path.resolve(addPath);
|
|
788
|
+
try {
|
|
789
|
+
var addStat = fs.statSync(addAbs);
|
|
790
|
+
if (!addStat.isDirectory()) {
|
|
791
|
+
sendTo(ws, { type: "add_project_result", ok: false, error: "Not a directory" });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
} catch (e) {
|
|
795
|
+
sendTo(ws, { type: "add_project_result", ok: false, error: "Directory not found" });
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (typeof opts.onAddProject === "function") {
|
|
799
|
+
var result = opts.onAddProject(addAbs);
|
|
800
|
+
sendTo(ws, { type: "add_project_result", ok: result.ok, slug: result.slug, error: result.error, existing: result.existing });
|
|
801
|
+
} else {
|
|
802
|
+
sendTo(ws, { type: "add_project_result", ok: false, error: "Not supported" });
|
|
803
|
+
}
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// --- Remove project from web UI ---
|
|
808
|
+
if (msg.type === "remove_project") {
|
|
809
|
+
var removeSlug = msg.slug;
|
|
810
|
+
if (!removeSlug) {
|
|
811
|
+
sendTo(ws, { type: "remove_project_result", ok: false, error: "Missing slug" });
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (typeof opts.onRemoveProject === "function") {
|
|
815
|
+
var removeResult = opts.onRemoveProject(removeSlug);
|
|
816
|
+
sendTo(ws, { type: "remove_project_result", ok: removeResult.ok, slug: removeSlug, error: removeResult.error });
|
|
817
|
+
} else {
|
|
818
|
+
sendTo(ws, { type: "remove_project_result", ok: false, error: "Not supported" });
|
|
819
|
+
}
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// --- Daemon config (server settings) ---
|
|
824
|
+
if (msg.type === "get_daemon_config") {
|
|
825
|
+
if (typeof opts.onGetDaemonConfig === "function") {
|
|
826
|
+
var daemonConfig = opts.onGetDaemonConfig();
|
|
827
|
+
sendTo(ws, { type: "daemon_config", config: daemonConfig });
|
|
828
|
+
}
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (msg.type === "set_pin") {
|
|
833
|
+
if (typeof opts.onSetPin === "function") {
|
|
834
|
+
var pinResult = opts.onSetPin(msg.pin || null);
|
|
835
|
+
sendTo(ws, { type: "set_pin_result", ok: pinResult.ok, pinEnabled: pinResult.pinEnabled });
|
|
836
|
+
}
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (msg.type === "set_keep_awake") {
|
|
841
|
+
if (typeof opts.onSetKeepAwake === "function") {
|
|
842
|
+
var kaResult = opts.onSetKeepAwake(msg.value);
|
|
843
|
+
sendTo(ws, { type: "set_keep_awake_result", ok: kaResult.ok, keepAwake: kaResult.keepAwake });
|
|
844
|
+
send({ type: "keep_awake_changed", keepAwake: kaResult.keepAwake });
|
|
845
|
+
}
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (msg.type === "shutdown_server") {
|
|
850
|
+
if (typeof opts.onShutdown === "function") {
|
|
851
|
+
sendTo(ws, { type: "shutdown_server_result", ok: true });
|
|
852
|
+
send({ type: "toast", level: "warn", message: "Server is shutting down..." });
|
|
853
|
+
// Small delay so the response has time to reach clients
|
|
854
|
+
setTimeout(function () {
|
|
855
|
+
opts.onShutdown();
|
|
856
|
+
}, 500);
|
|
857
|
+
} else {
|
|
858
|
+
sendTo(ws, { type: "shutdown_server_result", ok: false, error: "Shutdown not supported" });
|
|
859
|
+
}
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// --- File browser ---
|
|
864
|
+
if (msg.type === "fs_list") {
|
|
865
|
+
var fsDir = safePath(cwd, msg.path || ".");
|
|
866
|
+
if (!fsDir) {
|
|
867
|
+
sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: "Access denied" });
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
var items = fs.readdirSync(fsDir, { withFileTypes: true });
|
|
872
|
+
var entries = [];
|
|
873
|
+
for (var fi = 0; fi < items.length; fi++) {
|
|
874
|
+
var item = items[fi];
|
|
875
|
+
if (item.isDirectory() && IGNORED_DIRS.has(item.name)) continue;
|
|
876
|
+
entries.push({
|
|
877
|
+
name: item.name,
|
|
878
|
+
type: item.isDirectory() ? "dir" : "file",
|
|
879
|
+
path: path.relative(cwd, path.join(fsDir, item.name)).split(path.sep).join("/"),
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
sendTo(ws, { type: "fs_list_result", path: msg.path || ".", entries: entries });
|
|
883
|
+
// Auto-watch the directory for changes
|
|
884
|
+
startDirWatch(msg.path || ".");
|
|
885
|
+
} catch (e) {
|
|
886
|
+
sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: e.message });
|
|
887
|
+
}
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (msg.type === "fs_read") {
|
|
892
|
+
var fsFile = safePath(cwd, msg.path);
|
|
893
|
+
if (!fsFile) {
|
|
894
|
+
sendTo(ws, { type: "fs_read_result", path: msg.path, error: "Access denied" });
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
var stat = fs.statSync(fsFile);
|
|
899
|
+
var ext = path.extname(fsFile).toLowerCase();
|
|
900
|
+
if (stat.size > FS_MAX_SIZE) {
|
|
901
|
+
sendTo(ws, { type: "fs_read_result", path: msg.path, binary: true, size: stat.size, error: "File too large (" + (stat.size / 1024 / 1024).toFixed(1) + " MB)" });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (BINARY_EXTS.has(ext)) {
|
|
905
|
+
var result = { type: "fs_read_result", path: msg.path, binary: true, size: stat.size };
|
|
906
|
+
if (IMAGE_EXTS.has(ext)) result.imageUrl = "api/file?path=" + encodeURIComponent(msg.path);
|
|
907
|
+
sendTo(ws, result);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
var content = fs.readFileSync(fsFile, "utf8");
|
|
911
|
+
sendTo(ws, { type: "fs_read_result", path: msg.path, content: content, size: stat.size });
|
|
912
|
+
} catch (e) {
|
|
913
|
+
sendTo(ws, { type: "fs_read_result", path: msg.path, error: e.message });
|
|
914
|
+
}
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// --- File watcher ---
|
|
919
|
+
if (msg.type === "fs_watch") {
|
|
920
|
+
if (msg.path) startFileWatch(msg.path);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (msg.type === "fs_unwatch") {
|
|
925
|
+
stopFileWatch();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// --- File edit history ---
|
|
930
|
+
if (msg.type === "fs_file_history") {
|
|
931
|
+
var histPath = msg.path;
|
|
932
|
+
if (!histPath) {
|
|
933
|
+
sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: [] });
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
var absHistPath = path.resolve(cwd, histPath);
|
|
937
|
+
var entries = [];
|
|
938
|
+
|
|
939
|
+
// Collect session edits
|
|
940
|
+
sm.sessions.forEach(function (session) {
|
|
941
|
+
var sessionLocalId = session.localId;
|
|
942
|
+
var sessionTitle = session.title || "Untitled";
|
|
943
|
+
var histLen = session.history.length || 1;
|
|
944
|
+
|
|
945
|
+
for (var hi = 0; hi < session.history.length; hi++) {
|
|
946
|
+
var entry = session.history[hi];
|
|
947
|
+
if (entry.type !== "tool_executing") continue;
|
|
948
|
+
if (entry.name !== "Edit" && entry.name !== "Write") continue;
|
|
949
|
+
if (!entry.input || !entry.input.file_path) continue;
|
|
950
|
+
if (entry.input.file_path !== absHistPath) continue;
|
|
951
|
+
|
|
952
|
+
// Find parent assistant UUID + message snippet by scanning backwards
|
|
953
|
+
var assistantUuid = null;
|
|
954
|
+
var uuidIndex = -1;
|
|
955
|
+
for (var hj = hi - 1; hj >= 0; hj--) {
|
|
956
|
+
if (session.history[hj].type === "message_uuid" && session.history[hj].messageType === "assistant") {
|
|
957
|
+
assistantUuid = session.history[hj].uuid;
|
|
958
|
+
uuidIndex = hj;
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Find user prompt by scanning backwards from the assistant uuid
|
|
964
|
+
var messageSnippet = "";
|
|
965
|
+
var searchFrom = uuidIndex >= 0 ? uuidIndex : hi;
|
|
966
|
+
for (var hk = searchFrom - 1; hk >= 0; hk--) {
|
|
967
|
+
if (session.history[hk].type === "user_message" && session.history[hk].text) {
|
|
968
|
+
messageSnippet = session.history[hk].text.trim().substring(0, 100);
|
|
969
|
+
break;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Collect Claude's explanation: scan backwards from tool_executing
|
|
974
|
+
// to find the nearest delta text block (skipping tool_start).
|
|
975
|
+
// If no delta found immediately before this tool, scan past
|
|
976
|
+
// intervening tool blocks to find the last delta text within
|
|
977
|
+
// the same assistant turn.
|
|
978
|
+
var assistantSnippet = "";
|
|
979
|
+
var deltaChunks = [];
|
|
980
|
+
for (var hd = hi - 1; hd >= 0; hd--) {
|
|
981
|
+
var hEntry = session.history[hd];
|
|
982
|
+
if (hEntry.type === "tool_start") continue;
|
|
983
|
+
if (hEntry.type === "delta" && hEntry.text) {
|
|
984
|
+
deltaChunks.unshift(hEntry.text);
|
|
985
|
+
} else {
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
if (deltaChunks.length === 0) {
|
|
990
|
+
// No delta immediately before; scan past tool blocks
|
|
991
|
+
// to find the nearest preceding delta in the same turn
|
|
992
|
+
for (var hd2 = hi - 1; hd2 >= 0; hd2--) {
|
|
993
|
+
var hEntry2 = session.history[hd2];
|
|
994
|
+
if (hEntry2.type === "tool_start" || hEntry2.type === "tool_executing" || hEntry2.type === "tool_result") continue;
|
|
995
|
+
if (hEntry2.type === "delta" && hEntry2.text) {
|
|
996
|
+
// Found a delta before an earlier tool in the same turn.
|
|
997
|
+
// Collect this contiguous block of deltas.
|
|
998
|
+
for (var hd3 = hd2; hd3 >= 0; hd3--) {
|
|
999
|
+
var hEntry3 = session.history[hd3];
|
|
1000
|
+
if (hEntry3.type === "tool_start") continue;
|
|
1001
|
+
if (hEntry3.type === "delta" && hEntry3.text) {
|
|
1002
|
+
deltaChunks.unshift(hEntry3.text);
|
|
1003
|
+
} else {
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
break;
|
|
1008
|
+
} else {
|
|
1009
|
+
// Hit message_uuid, user_message, etc. Stop.
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
assistantSnippet = deltaChunks.join("").trim().substring(0, 150);
|
|
1015
|
+
|
|
1016
|
+
// Approximate timestamp: interpolate between session creation and last activity
|
|
1017
|
+
var tStart = session.createdAt || 0;
|
|
1018
|
+
var tEnd = session.lastActivity || tStart;
|
|
1019
|
+
var ts = tStart + Math.floor((hi / histLen) * (tEnd - tStart));
|
|
1020
|
+
|
|
1021
|
+
var editRecord = {
|
|
1022
|
+
source: "session",
|
|
1023
|
+
timestamp: ts,
|
|
1024
|
+
sessionLocalId: sessionLocalId,
|
|
1025
|
+
sessionTitle: sessionTitle,
|
|
1026
|
+
assistantUuid: assistantUuid,
|
|
1027
|
+
toolId: entry.id,
|
|
1028
|
+
messageSnippet: messageSnippet,
|
|
1029
|
+
assistantSnippet: assistantSnippet,
|
|
1030
|
+
toolName: entry.name,
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
if (entry.name === "Edit") {
|
|
1034
|
+
editRecord.old_string = entry.input.old_string || "";
|
|
1035
|
+
editRecord.new_string = entry.input.new_string || "";
|
|
1036
|
+
} else {
|
|
1037
|
+
editRecord.isFullWrite = true;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
entries.push(editRecord);
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// Collect git commits
|
|
1045
|
+
try {
|
|
1046
|
+
var gitLog = execFileSync(
|
|
1047
|
+
"git", ["log", "--format=%H|%at|%an|%s", "--follow", "--", histPath],
|
|
1048
|
+
{ cwd: cwd, encoding: "utf8", timeout: 5000 }
|
|
1049
|
+
);
|
|
1050
|
+
var gitLines = gitLog.trim().split("\n");
|
|
1051
|
+
for (var gi = 0; gi < gitLines.length; gi++) {
|
|
1052
|
+
if (!gitLines[gi]) continue;
|
|
1053
|
+
var parts = gitLines[gi].split("|");
|
|
1054
|
+
if (parts.length < 4) continue;
|
|
1055
|
+
entries.push({
|
|
1056
|
+
source: "git",
|
|
1057
|
+
hash: parts[0],
|
|
1058
|
+
timestamp: parseInt(parts[1], 10) * 1000,
|
|
1059
|
+
author: parts[2],
|
|
1060
|
+
message: parts.slice(3).join("|"),
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
// Not a git repo or file not tracked, that's fine
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Sort by timestamp descending (newest first)
|
|
1068
|
+
entries.sort(function (a, b) { return b.timestamp - a.timestamp; });
|
|
1069
|
+
|
|
1070
|
+
sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: entries });
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// --- Git diff for file history ---
|
|
1075
|
+
if (msg.type === "fs_git_diff") {
|
|
1076
|
+
var diffPath = msg.path;
|
|
1077
|
+
var hash = msg.hash;
|
|
1078
|
+
var hash2 = msg.hash2 || null;
|
|
1079
|
+
if (!diffPath || !hash) {
|
|
1080
|
+
sendTo(ws, { type: "fs_git_diff_result", hash: hash, path: diffPath, diff: "", error: "Missing params" });
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
var diff;
|
|
1085
|
+
if (hash2) {
|
|
1086
|
+
diff = execFileSync("git", ["diff", hash, hash2, "--", diffPath],
|
|
1087
|
+
{ cwd: cwd, encoding: "utf8", timeout: 5000 });
|
|
1088
|
+
} else {
|
|
1089
|
+
diff = execFileSync("git", ["show", hash, "--format=", "--", diffPath],
|
|
1090
|
+
{ cwd: cwd, encoding: "utf8", timeout: 5000 });
|
|
1091
|
+
}
|
|
1092
|
+
sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: diff || "" });
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: "", error: e.message });
|
|
1095
|
+
}
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// --- File content at a git commit ---
|
|
1100
|
+
if (msg.type === "fs_file_at") {
|
|
1101
|
+
var atPath = msg.path;
|
|
1102
|
+
var atHash = msg.hash;
|
|
1103
|
+
if (!atPath || !atHash) {
|
|
1104
|
+
sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: "Missing params" });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
// Convert to repo-relative path (git show requires hash:relative/path)
|
|
1109
|
+
var atAbsPath = path.resolve(cwd, atPath);
|
|
1110
|
+
var atRelPath = path.relative(cwd, atAbsPath);
|
|
1111
|
+
var content = execFileSync("git", ["show", atHash + ":" + atRelPath],
|
|
1112
|
+
{ cwd: cwd, encoding: "utf8", timeout: 5000 });
|
|
1113
|
+
sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: content });
|
|
1114
|
+
} catch (e) {
|
|
1115
|
+
sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: e.message });
|
|
1116
|
+
}
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// --- Sticky notes ---
|
|
1121
|
+
if (msg.type === "note_create") {
|
|
1122
|
+
var note = nm.create(msg);
|
|
1123
|
+
if (note) send({ type: "note_created", note: note });
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (msg.type === "note_update") {
|
|
1128
|
+
if (!msg.id) return;
|
|
1129
|
+
var updated = nm.update(msg.id, msg);
|
|
1130
|
+
if (updated) send({ type: "note_updated", note: updated });
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (msg.type === "note_delete") {
|
|
1135
|
+
if (!msg.id) return;
|
|
1136
|
+
if (nm.remove(msg.id)) send({ type: "note_deleted", id: msg.id });
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (msg.type === "note_bring_front") {
|
|
1141
|
+
if (!msg.id) return;
|
|
1142
|
+
var front = nm.bringToFront(msg.id);
|
|
1143
|
+
if (front) send({ type: "note_updated", note: front });
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// --- Web terminal ---
|
|
1148
|
+
if (msg.type === "term_create") {
|
|
1149
|
+
var t = tm.create(msg.cols || 80, msg.rows || 24);
|
|
1150
|
+
if (!t) {
|
|
1151
|
+
sendTo(ws, { type: "term_error", error: "Cannot create terminal (node-pty not available or limit reached)" });
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
tm.attach(t.id, ws);
|
|
1155
|
+
send({ type: "term_list", terminals: tm.list() });
|
|
1156
|
+
sendTo(ws, { type: "term_created", id: t.id });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (msg.type === "term_attach") {
|
|
1161
|
+
if (msg.id) tm.attach(msg.id, ws);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (msg.type === "term_detach") {
|
|
1166
|
+
if (msg.id) tm.detach(msg.id, ws);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (msg.type === "term_input") {
|
|
1171
|
+
if (msg.id) tm.write(msg.id, msg.data);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
if (msg.type === "term_resize") {
|
|
1176
|
+
if (msg.id && msg.cols > 0 && msg.rows > 0) {
|
|
1177
|
+
tm.resize(msg.id, msg.cols, msg.rows);
|
|
1178
|
+
}
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (msg.type === "term_close") {
|
|
1183
|
+
if (msg.id) {
|
|
1184
|
+
tm.close(msg.id);
|
|
1185
|
+
send({ type: "term_list", terminals: tm.list() });
|
|
1186
|
+
}
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (msg.type === "term_rename") {
|
|
1191
|
+
if (msg.id && msg.title) {
|
|
1192
|
+
tm.rename(msg.id, msg.title);
|
|
1193
|
+
send({ type: "term_list", terminals: tm.list() });
|
|
1194
|
+
}
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (msg.type !== "message") return;
|
|
1199
|
+
if (!msg.text && (!msg.images || msg.images.length === 0) && (!msg.pastes || msg.pastes.length === 0)) return;
|
|
1200
|
+
|
|
1201
|
+
var session = sm.getActiveSession();
|
|
1202
|
+
if (!session) return;
|
|
1203
|
+
|
|
1204
|
+
var userMsg = { type: "user_message", text: msg.text || "" };
|
|
1205
|
+
if (msg.images && msg.images.length > 0) {
|
|
1206
|
+
userMsg.imageCount = msg.images.length;
|
|
1207
|
+
}
|
|
1208
|
+
if (msg.pastes && msg.pastes.length > 0) {
|
|
1209
|
+
userMsg.pastes = msg.pastes;
|
|
1210
|
+
}
|
|
1211
|
+
session.history.push(userMsg);
|
|
1212
|
+
sm.appendToSessionFile(session, userMsg);
|
|
1213
|
+
sendToOthers(ws, userMsg);
|
|
1214
|
+
|
|
1215
|
+
if (!session.title) {
|
|
1216
|
+
session.title = (msg.text || "Image").substring(0, 50);
|
|
1217
|
+
sm.saveSessionFile(session);
|
|
1218
|
+
sm.broadcastSessionList();
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
var fullText = msg.text || "";
|
|
1222
|
+
if (msg.pastes && msg.pastes.length > 0) {
|
|
1223
|
+
for (var pi = 0; pi < msg.pastes.length; pi++) {
|
|
1224
|
+
if (fullText) fullText += "\n\n";
|
|
1225
|
+
fullText += msg.pastes[pi];
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (!session.isProcessing) {
|
|
1230
|
+
session.isProcessing = true;
|
|
1231
|
+
session.sentToolResults = {};
|
|
1232
|
+
send({ type: "status", status: "processing" });
|
|
1233
|
+
if (!session.queryInstance) {
|
|
1234
|
+
sdk.startQuery(session, fullText, msg.images);
|
|
1235
|
+
} else {
|
|
1236
|
+
sdk.pushMessage(session, fullText, msg.images);
|
|
1237
|
+
}
|
|
1238
|
+
} else {
|
|
1239
|
+
sdk.pushMessage(session, fullText, msg.images);
|
|
1240
|
+
}
|
|
1241
|
+
sm.broadcastSessionList();
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// --- WS disconnection handler ---
|
|
1245
|
+
function handleDisconnection(ws) {
|
|
1246
|
+
tm.detachAll(ws);
|
|
1247
|
+
clients.delete(ws);
|
|
1248
|
+
if (clients.size === 0) {
|
|
1249
|
+
stopFileWatch();
|
|
1250
|
+
stopAllDirWatches();
|
|
1251
|
+
}
|
|
1252
|
+
broadcastClientCount();
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// --- Handle project-scoped HTTP requests ---
|
|
1256
|
+
function handleHTTP(req, res, urlPath) {
|
|
1257
|
+
// Push subscribe
|
|
1258
|
+
if (req.method === "POST" && urlPath === "/api/push-subscribe") {
|
|
1259
|
+
parseJsonBody(req).then(function (body) {
|
|
1260
|
+
var sub = body.subscription || body;
|
|
1261
|
+
if (pushModule) pushModule.addSubscription(sub, body.replaceEndpoint);
|
|
1262
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1263
|
+
res.end('{"ok":true}');
|
|
1264
|
+
}).catch(function () {
|
|
1265
|
+
res.writeHead(400);
|
|
1266
|
+
res.end("Bad request");
|
|
1267
|
+
});
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Permission response from push notification
|
|
1272
|
+
if (req.method === "POST" && urlPath === "/api/permission-response") {
|
|
1273
|
+
parseJsonBody(req).then(function (data) {
|
|
1274
|
+
var requestId = data.requestId;
|
|
1275
|
+
var decision = data.decision;
|
|
1276
|
+
if (!requestId || !decision) {
|
|
1277
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1278
|
+
res.end('{"error":"missing requestId or decision"}');
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
var found = false;
|
|
1282
|
+
sm.sessions.forEach(function (session) {
|
|
1283
|
+
var pending = session.pendingPermissions[requestId];
|
|
1284
|
+
if (!pending) return;
|
|
1285
|
+
found = true;
|
|
1286
|
+
delete session.pendingPermissions[requestId];
|
|
1287
|
+
if (decision === "allow") {
|
|
1288
|
+
pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
|
|
1289
|
+
} else {
|
|
1290
|
+
pending.resolve({ behavior: "deny", message: "Denied via push notification" });
|
|
1291
|
+
}
|
|
1292
|
+
sm.sendAndRecord(session, {
|
|
1293
|
+
type: "permission_resolved",
|
|
1294
|
+
requestId: requestId,
|
|
1295
|
+
decision: decision,
|
|
1296
|
+
});
|
|
1297
|
+
});
|
|
1298
|
+
if (found) {
|
|
1299
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1300
|
+
res.end('{"ok":true}');
|
|
1301
|
+
} else {
|
|
1302
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1303
|
+
res.end('{"error":"permission request not found"}');
|
|
1304
|
+
}
|
|
1305
|
+
}).catch(function () {
|
|
1306
|
+
res.writeHead(400);
|
|
1307
|
+
res.end("Bad request");
|
|
1308
|
+
});
|
|
1309
|
+
return true;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// VAPID public key
|
|
1313
|
+
if (req.method === "GET" && urlPath === "/api/vapid-public-key") {
|
|
1314
|
+
if (pushModule) {
|
|
1315
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store" });
|
|
1316
|
+
res.end(JSON.stringify({ publicKey: pushModule.publicKey }));
|
|
1317
|
+
} else {
|
|
1318
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1319
|
+
res.end('{"error":"push not available"}');
|
|
1320
|
+
}
|
|
1321
|
+
return true;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// File browser: serve project images
|
|
1325
|
+
if (req.method === "GET" && urlPath.startsWith("/api/file?")) {
|
|
1326
|
+
var qIdx = urlPath.indexOf("?");
|
|
1327
|
+
var params = new URLSearchParams(urlPath.substring(qIdx));
|
|
1328
|
+
var reqFilePath = params.get("path");
|
|
1329
|
+
if (!reqFilePath) { res.writeHead(400); res.end("Missing path"); return true; }
|
|
1330
|
+
var absFile = safePath(cwd, reqFilePath);
|
|
1331
|
+
if (!absFile) { res.writeHead(403); res.end("Access denied"); return true; }
|
|
1332
|
+
var fileExt = path.extname(absFile).toLowerCase();
|
|
1333
|
+
if (!IMAGE_EXTS.has(fileExt)) { res.writeHead(403); res.end("Only image files"); return true; }
|
|
1334
|
+
try {
|
|
1335
|
+
var fileContent = fs.readFileSync(absFile);
|
|
1336
|
+
var fileMime = MIME_TYPES[fileExt] || "application/octet-stream";
|
|
1337
|
+
res.writeHead(200, { "Content-Type": fileMime, "Cache-Control": "no-cache" });
|
|
1338
|
+
res.end(fileContent);
|
|
1339
|
+
} catch (e) {
|
|
1340
|
+
res.writeHead(404); res.end("Not found");
|
|
1341
|
+
}
|
|
1342
|
+
return true;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// Info endpoint
|
|
1346
|
+
if (req.method === "GET" && urlPath === "/info") {
|
|
1347
|
+
res.writeHead(200, {
|
|
1348
|
+
"Content-Type": "application/json",
|
|
1349
|
+
"Access-Control-Allow-Origin": "*",
|
|
1350
|
+
});
|
|
1351
|
+
res.end(JSON.stringify({ cwd: cwd, project: project, slug: slug }));
|
|
1352
|
+
return true;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
return false; // not handled
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// --- Destroy ---
|
|
1359
|
+
function destroy() {
|
|
1360
|
+
stopFileWatch();
|
|
1361
|
+
stopAllDirWatches();
|
|
1362
|
+
// Abort all active sessions
|
|
1363
|
+
sm.sessions.forEach(function (session) {
|
|
1364
|
+
if (session.abortController) {
|
|
1365
|
+
try { session.abortController.abort(); } catch (e) {}
|
|
1366
|
+
}
|
|
1367
|
+
if (session.messageQueue) {
|
|
1368
|
+
try { session.messageQueue.end(); } catch (e) {}
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
// Kill all terminals
|
|
1372
|
+
tm.destroyAll();
|
|
1373
|
+
for (var ws of clients) {
|
|
1374
|
+
try { ws.close(); } catch (e) {}
|
|
1375
|
+
}
|
|
1376
|
+
clients.clear();
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// --- Status info ---
|
|
1380
|
+
function getStatus() {
|
|
1381
|
+
var sessionCount = sm.sessions.size;
|
|
1382
|
+
var hasProcessing = false;
|
|
1383
|
+
sm.sessions.forEach(function (s) {
|
|
1384
|
+
if (s.isProcessing) hasProcessing = true;
|
|
1385
|
+
});
|
|
1386
|
+
return {
|
|
1387
|
+
slug: slug,
|
|
1388
|
+
path: cwd,
|
|
1389
|
+
project: project,
|
|
1390
|
+
title: title,
|
|
1391
|
+
clients: clients.size,
|
|
1392
|
+
sessions: sessionCount,
|
|
1393
|
+
isProcessing: hasProcessing,
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function setTitle(newTitle) {
|
|
1398
|
+
title = newTitle || null;
|
|
1399
|
+
send({ type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return {
|
|
1403
|
+
cwd: cwd,
|
|
1404
|
+
slug: slug,
|
|
1405
|
+
project: project,
|
|
1406
|
+
clients: clients,
|
|
1407
|
+
sm: sm,
|
|
1408
|
+
sdk: sdk,
|
|
1409
|
+
send: send,
|
|
1410
|
+
sendTo: sendTo,
|
|
1411
|
+
handleConnection: handleConnection,
|
|
1412
|
+
handleMessage: handleMessage,
|
|
1413
|
+
handleDisconnection: handleDisconnection,
|
|
1414
|
+
handleHTTP: handleHTTP,
|
|
1415
|
+
getStatus: getStatus,
|
|
1416
|
+
setTitle: setTitle,
|
|
1417
|
+
warmup: function () { sdk.warmup(); },
|
|
1418
|
+
destroy: destroy,
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function parseJsonBody(req) {
|
|
1423
|
+
return new Promise(function (resolve, reject) {
|
|
1424
|
+
var body = "";
|
|
1425
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
1426
|
+
req.on("end", function () {
|
|
1427
|
+
try { resolve(JSON.parse(body)); }
|
|
1428
|
+
catch (e) { reject(e); }
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
module.exports = { createProjectContext: createProjectContext };
|