clay-server 5.1.0-beta.2 → 6.0.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.
- package/bin/cli.js +9 -91
- package/lib/daemon-settings.js +11 -0
- package/lib/daemon.js +8 -37
- package/lib/linear-store.js +35 -8
- package/lib/os-users.js +5 -16
- package/lib/project-connection.js +0 -7
- package/lib/project-filesystem.js +21 -39
- package/lib/project-mate-interaction.js +2 -2
- package/lib/project-server-settings.js +31 -0
- package/lib/project-sessions.js +57 -184
- package/lib/public/app.js +6 -37
- package/lib/public/css/input.css +3 -1
- package/lib/public/css/project-settings-modal.css +56 -0
- package/lib/public/css/server-settings-system.css +125 -0
- package/lib/public/css/session-github.css +25 -16
- package/lib/public/css/settings-ui.css +247 -22
- package/lib/public/css/shared-browser.css +2 -0
- package/lib/public/css/title-bar.css +5 -1
- package/lib/public/index.html +170 -446
- package/lib/public/modules/account-island.js +9 -7
- package/lib/public/modules/admin-settings-email.js +197 -0
- package/lib/public/modules/admin-settings-invitations.js +183 -0
- package/lib/public/modules/admin-settings-projects.js +198 -0
- package/lib/public/modules/admin-settings-ui.js +100 -0
- package/lib/public/modules/admin-settings-users.js +331 -0
- package/lib/public/modules/admin.js +19 -949
- package/lib/public/modules/app-dm.js +0 -2
- package/lib/public/modules/app-messages.js +13 -52
- package/lib/public/modules/app-rate-limit.js +70 -12
- package/lib/public/modules/context-sources.js +27 -18
- package/lib/public/modules/default-ai.js +14 -7
- package/lib/public/modules/header-tui-font.js +10 -20
- package/lib/public/modules/linear-settings.js +3 -2
- package/lib/public/modules/markdown.js +5 -6
- package/lib/public/modules/notification-settings.js +81 -0
- package/lib/public/modules/notifications.js +44 -28
- package/lib/public/modules/personal-settings-dialog.js +51 -0
- package/lib/public/modules/profile.js +15 -6
- package/lib/public/modules/project-settings-environment.js +106 -0
- package/lib/public/modules/project-settings.js +72 -628
- package/lib/public/modules/server-settings-controls.js +316 -0
- package/lib/public/modules/server-settings-environment.js +194 -0
- package/lib/public/modules/server-settings-navigation.js +39 -0
- package/lib/public/modules/server-settings-state.js +54 -0
- package/lib/public/modules/server-settings.js +116 -788
- package/lib/public/modules/server-storage-settings.js +84 -0
- package/lib/public/modules/session-github.js +2 -1
- package/lib/public/modules/session-presence-ui.js +1 -1
- package/lib/public/modules/settings-env-values.js +34 -0
- package/lib/public/modules/settings-select.js +121 -0
- package/lib/public/modules/shared-browser.js +10 -7
- package/lib/public/modules/stt-picker.js +14 -5
- package/lib/public/modules/terminal-prefs.js +22 -0
- package/lib/public/modules/theme.js +88 -52
- package/lib/public/modules/user-settings-account.js +318 -0
- package/lib/public/modules/user-settings-appearance.js +55 -0
- package/lib/public/modules/user-settings-editors.js +24 -0
- package/lib/public/modules/user-settings-integrations.js +104 -0
- package/lib/public/modules/user-settings-layout.js +80 -0
- package/lib/public/modules/user-settings-preferences.js +48 -0
- package/lib/public/modules/user-settings-profile.js +53 -0
- package/lib/public/modules/user-settings-themes.js +38 -0
- package/lib/public/modules/user-settings-voice.js +75 -0
- package/lib/public/modules/user-settings.js +53 -480
- package/lib/public/style.css +4 -0
- package/lib/sdk-message-processor.js +16 -4
- package/lib/sdk-quota-snapshot.js +37 -0
- package/lib/server-appearance-settings.js +71 -0
- package/lib/server-linear.js +14 -8
- package/lib/server-notification-settings.js +67 -0
- package/lib/server-settings.js +4 -0
- package/lib/server-storage.js +56 -0
- package/lib/server.js +10 -2
- package/lib/sessions.js +2 -3
- package/lib/startup-projects.js +40 -0
- package/lib/storage-usage.js +127 -0
- package/lib/users-preferences.js +0 -74
- package/lib/users.js +0 -4
- package/lib/ws-schema.js +2 -4
- package/lib/yoke/adapters/codex.js +8 -45
- package/lib/yoke/codex-model-catalog.js +40 -0
- package/lib/yoke/codex-rate-limits.js +26 -0
- package/package.json +3 -3
|
@@ -6,11 +6,13 @@ var yoke = require("./yoke");
|
|
|
6
6
|
var backgroundTaskTiming = require("./background-task-timing");
|
|
7
7
|
var sessionTitlePolicy = require("./session-title-policy");
|
|
8
8
|
var askUserIdentity = require("./ask-user-request-identity");
|
|
9
|
+
var quotaSnapshots = require("./sdk-quota-snapshot");
|
|
9
10
|
|
|
10
11
|
var OVERLOAD_RETRY_LIMIT = 3;
|
|
11
12
|
|
|
12
13
|
function attachMessageProcessor(ctx) {
|
|
13
14
|
var sm = ctx.sm;
|
|
15
|
+
quotaSnapshots.attachQuotaSnapshots(sm);
|
|
14
16
|
var send = ctx.send;
|
|
15
17
|
var slug = ctx.slug;
|
|
16
18
|
var isMate = ctx.isMate;
|
|
@@ -653,11 +655,13 @@ function attachMessageProcessor(ctx) {
|
|
|
653
655
|
session._queuedTurnCount = 0;
|
|
654
656
|
onProcessingChanged();
|
|
655
657
|
}
|
|
656
|
-
//
|
|
658
|
+
// Recognize only short, zero-cost CLI login responses, not ordinary prose.
|
|
657
659
|
var previewTrimmed = (session.responsePreview || "").trim();
|
|
658
660
|
var isZeroCost = !parsed.cost || parsed.cost === 0;
|
|
659
661
|
var isLoginPrompt = isZeroCost && previewTrimmed.length < 100
|
|
660
|
-
&& /not logged in/i.test(previewTrimmed) && /\/login/i.test(previewTrimmed)
|
|
662
|
+
&& ((/not logged in/i.test(previewTrimmed) && /\/login/i.test(previewTrimmed))
|
|
663
|
+
|| ((session.vendor || "claude") === "claude"
|
|
664
|
+
&& /^Failed to authenticate: OAuth session expired and could not be refreshed\.?$/i.test(previewTrimmed)));
|
|
661
665
|
// Fetch rich context usage breakdown (fire-and-forget, non-blocking)
|
|
662
666
|
if (session.queryInstance && typeof session.queryInstance.getContextUsage === "function") {
|
|
663
667
|
var contextQuery = session.queryInstance;
|
|
@@ -692,7 +696,7 @@ function attachMessageProcessor(ctx) {
|
|
|
692
696
|
if (parsed.fastModeState) {
|
|
693
697
|
sendAndRecord(session, { type: "fast_mode_state", state: parsed.fastModeState });
|
|
694
698
|
}
|
|
695
|
-
//
|
|
699
|
+
// Recover both missing login and expired OAuth sessions from the SDK.
|
|
696
700
|
// This is a short canned response with zero cost, not actual AI output.
|
|
697
701
|
if (isLoginPrompt) {
|
|
698
702
|
emitAuthRequired(session);
|
|
@@ -853,6 +857,10 @@ function attachMessageProcessor(ctx) {
|
|
|
853
857
|
|
|
854
858
|
} else if (parsed.yokeType === "rate_limit") {
|
|
855
859
|
var info = parsed.rateLimitInfo;
|
|
860
|
+
if (info.source === "account_snapshot") {
|
|
861
|
+
quotaSnapshots.recordQuotaSnapshot(session, info, sendAndRecord);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
856
864
|
console.log("[sdk-bridge] rate_limit_event for session " + session.localId + ": status=" + info.status + " resetsAt=" + info.resetsAt + " isUsingOverage=" + info.isUsingOverage + " isProcessing=" + session.isProcessing);
|
|
857
865
|
|
|
858
866
|
// Persist the scoped reset snapshot so reconnect/history restore can reproject it.
|
|
@@ -877,7 +885,11 @@ function attachMessageProcessor(ctx) {
|
|
|
877
885
|
resetsAt: info.resetsAt ? info.resetsAt * 1000 : null,
|
|
878
886
|
rateLimitType: info.rateLimitType || null,
|
|
879
887
|
utilization: info.utilization || null,
|
|
880
|
-
isUsingOverage: info.isUsingOverage ||
|
|
888
|
+
isUsingOverage: info.isUsingOverage === true || info.overageInUse === true,
|
|
889
|
+
overageEnabled: typeof info.overageEnabled === "boolean" ? info.overageEnabled : null,
|
|
890
|
+
overageStatus: info.overageStatus || null,
|
|
891
|
+
overageResetsAt: info.overageResetsAt ? info.overageResetsAt * 1000 : null,
|
|
892
|
+
overageDisabledReason: info.overageDisabledReason || null,
|
|
881
893
|
});
|
|
882
894
|
// Track rejection for auto-continue / scheduled message support
|
|
883
895
|
if (info.status === "rejected" && info.resetsAt) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function recordQuotaSnapshot(session, info, sendAndRecord) {
|
|
2
|
+
var message = {
|
|
3
|
+
type: "rate_limit_usage",
|
|
4
|
+
sessionId: session.localId,
|
|
5
|
+
vendor: session.vendor,
|
|
6
|
+
source: "account_snapshot",
|
|
7
|
+
limitId: info.limitId,
|
|
8
|
+
quotaWindows: info.windows.map(function (window) {
|
|
9
|
+
return Object.assign({}, window, { resetsAt: window.resetsAt ? window.resetsAt * 1000 : null });
|
|
10
|
+
}),
|
|
11
|
+
};
|
|
12
|
+
var snapshots = session._quotaSnapshots || (session._quotaSnapshots = Object.create(null));
|
|
13
|
+
var key = JSON.stringify([session.ownerId, session.vendor, info.limitId]);
|
|
14
|
+
var serialized = JSON.stringify(message);
|
|
15
|
+
if (snapshots[key] === serialized) return;
|
|
16
|
+
snapshots[key] = serialized;
|
|
17
|
+
sendAndRecord(session, message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function attachQuotaSnapshots(sm) {
|
|
21
|
+
if (typeof sm.addOnSessionViewed !== "function") return;
|
|
22
|
+
sm.addOnSessionViewed(function (session, ws, send) {
|
|
23
|
+
if (session.vendor !== "codex") return;
|
|
24
|
+
var seen = Object.create(null);
|
|
25
|
+
var history = session.history || [];
|
|
26
|
+
// A deduplicated snapshot may precede the initial page of chat history.
|
|
27
|
+
for (var i = history.length - 1; i >= 0; i--) {
|
|
28
|
+
var entry = history[i];
|
|
29
|
+
if (entry.type !== "rate_limit_usage" || entry.source !== "account_snapshot" || entry.vendor !== session.vendor) continue;
|
|
30
|
+
if (seen[entry.limitId]) continue;
|
|
31
|
+
seen[entry.limitId] = true;
|
|
32
|
+
send(Object.assign({}, entry, { sessionId: session.localId }));
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { recordQuotaSnapshot: recordQuotaSnapshot, attachQuotaSnapshots: attachQuotaSnapshots };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
var fs = require('fs');
|
|
2
|
+
var path = require('path');
|
|
3
|
+
|
|
4
|
+
function attachAppearanceSettings(ctx) {
|
|
5
|
+
var defaultPath = path.join(ctx.CONFIG_DIR, 'appearance-preferences.json');
|
|
6
|
+
function reply(res, code, data) {
|
|
7
|
+
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
8
|
+
res.end(JSON.stringify(data));
|
|
9
|
+
}
|
|
10
|
+
function identity(req) {
|
|
11
|
+
if (!ctx.isRequestAuthed(req)) return null;
|
|
12
|
+
if (!ctx.users.isMultiUser()) return { id: 'default' };
|
|
13
|
+
return ctx.getMultiUserFromReq(req);
|
|
14
|
+
}
|
|
15
|
+
function read(user) {
|
|
16
|
+
var saved;
|
|
17
|
+
if (ctx.users.isMultiUser()) saved = user.appearancePreferences;
|
|
18
|
+
else {
|
|
19
|
+
try { saved = JSON.parse(fs.readFileSync(defaultPath, 'utf8')); }
|
|
20
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
21
|
+
}
|
|
22
|
+
return Object.assign({ theme: saved && saved.theme || null, mode: saved && saved.mode || null, configured: !!saved }, saved && saved.lightTheme ? { lightTheme: saved.lightTheme } : {}, saved && saved.darkTheme ? { darkTheme: saved.darkTheme } : {});
|
|
23
|
+
}
|
|
24
|
+
function save(user, values) {
|
|
25
|
+
if (ctx.users.isMultiUser()) {
|
|
26
|
+
var data = ctx.users.loadUsers();
|
|
27
|
+
var current = data.users.find(function (entry) { return entry.id === user.id; });
|
|
28
|
+
if (!current) throw new Error('Account no longer available.');
|
|
29
|
+
current.appearancePreferences = values;
|
|
30
|
+
ctx.users.saveUsers(data);
|
|
31
|
+
} else {
|
|
32
|
+
fs.mkdirSync(ctx.CONFIG_DIR, { recursive: true });
|
|
33
|
+
fs.writeFileSync(defaultPath + '.tmp', JSON.stringify(values), { mode: 0o600 });
|
|
34
|
+
fs.renameSync(defaultPath + '.tmp', defaultPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function handleRequest(req, res, url) {
|
|
38
|
+
if (url !== '/api/user/appearance') return false;
|
|
39
|
+
var user = identity(req);
|
|
40
|
+
if (!user) { reply(res, 401, { error: 'Sign in to manage appearance.' }); return true; }
|
|
41
|
+
if (req.method === 'GET') {
|
|
42
|
+
try { reply(res, 200, read(user)); } catch (error) { reply(res, 500, { error: 'Could not read appearance preferences.' }); }
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (req.method !== 'PUT') { reply(res, 405, { error: 'Method not allowed.' }); return true; }
|
|
46
|
+
if (req.headers['x-clay-settings'] !== '1' || (req.headers['sec-fetch-site'] && ['same-origin', 'none'].indexOf(req.headers['sec-fetch-site']) < 0)) {
|
|
47
|
+
reply(res, 403, { error: 'Save preferences from Clay.' }); return true;
|
|
48
|
+
}
|
|
49
|
+
var body = ''; var tooLarge = false;
|
|
50
|
+
req.setEncoding('utf8');
|
|
51
|
+
req.on('data', function (chunk) { body += chunk; if (body.length > 4096 && !tooLarge) { tooLarge = true; reply(res, 413, { error: 'Request too large.' }); } });
|
|
52
|
+
req.on('end', function () {
|
|
53
|
+
if (tooLarge) return;
|
|
54
|
+
user = identity(req);
|
|
55
|
+
if (!user) { reply(res, 401, { error: 'Sign in again.' }); return; }
|
|
56
|
+
var values;
|
|
57
|
+
try { values = JSON.parse(body); } catch (error) { reply(res, 400, { error: 'Invalid JSON.' }); return; }
|
|
58
|
+
if (!values || (values.theme !== null && (typeof values.theme !== 'string' || !/^[a-zA-Z0-9_-]{1,120}$/.test(values.theme))) || [null, 'light', 'dark'].indexOf(values.mode) < 0) { reply(res, 400, { error: 'Invalid appearance preference.' }); return; }
|
|
59
|
+
if (['lightTheme', 'darkTheme'].some(function (field) { return values[field] !== undefined && (typeof values[field] !== 'string' || !/^[a-zA-Z0-9_-]{1,120}$/.test(values[field])); })) { reply(res, 400, { error: 'Invalid theme pair.' }); return; }
|
|
60
|
+
var previous;
|
|
61
|
+
try { previous = read(user); } catch (error) { reply(res, 500, { error: 'Could not read appearance preferences.' }); return; }
|
|
62
|
+
values = Object.assign({}, previous.lightTheme ? { lightTheme: previous.lightTheme } : {}, previous.darkTheme ? { darkTheme: previous.darkTheme } : {}, values);
|
|
63
|
+
values = Object.assign({ theme: values.theme, mode: values.mode }, values.lightTheme ? { lightTheme: values.lightTheme } : {}, values.darkTheme ? { darkTheme: values.darkTheme } : {});
|
|
64
|
+
try { save(user, values); reply(res, 200, values); }
|
|
65
|
+
catch (error) { reply(res, 500, { error: 'Could not save appearance preferences. Your draft is retained.' }); }
|
|
66
|
+
});
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return { handleRequest: handleRequest };
|
|
70
|
+
}
|
|
71
|
+
module.exports = { attachAppearanceSettings: attachAppearanceSettings };
|
package/lib/server-linear.js
CHANGED
|
@@ -10,39 +10,45 @@ function attachLinear(ctx) {
|
|
|
10
10
|
}
|
|
11
11
|
function json(res, status, body) { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); }
|
|
12
12
|
function handleRequest(req, res, url) {
|
|
13
|
-
|
|
13
|
+
var match = /^\/api\/linear\/connections(?:\/([a-zA-Z0-9_-]{1,64}))?$/.exec(url);
|
|
14
|
+
if (url !== '/api/linear/connection' && !match) return false;
|
|
15
|
+
var connectionId = match && match[1];
|
|
16
|
+
if (match && !connectionId && req.method !== 'GET') { json(res, 405, { error: 'Method not allowed.' }); return true; }
|
|
14
17
|
var id = owner(req);
|
|
15
18
|
if (!id) { json(res, 401, { error: 'Sign in to connect Linear.' }); return true; }
|
|
16
19
|
var origin = req.headers.origin;
|
|
17
20
|
try { if (req.headers['sec-fetch-site'] === 'cross-site' || (origin && new URL(origin).host !== req.headers.host)) throw new Error(); }
|
|
18
21
|
catch (error) { json(res, 403, { error: 'Invalid request origin.' }); return true; }
|
|
19
22
|
if (req.method === 'GET') {
|
|
20
|
-
try { json(res, 200, storage.view(id)); } catch (error) { json(res, 500, { error: 'Could not read Linear connection.' }); }
|
|
23
|
+
try { json(res, 200, match && !connectionId ? { connections: storage.list(id) } : storage.view(id, connectionId)); } catch (error) { json(res, 500, { error: 'Could not read Linear connection.' }); }
|
|
21
24
|
return true;
|
|
22
25
|
}
|
|
23
26
|
if (req.method !== 'PUT' && req.method !== 'DELETE') { json(res, 405, { error: 'Method not allowed.' }); return true; }
|
|
24
27
|
// A disconnect or newer save invalidates an older validation in flight.
|
|
25
|
-
var operation = {}; pending.set(
|
|
28
|
+
var operation = {}, pendingId = id + ':' + (connectionId || 'default'); pending.set(pendingId, operation);
|
|
26
29
|
var chunks = [], size = 0;
|
|
27
30
|
req.on('data', function (chunk) { size += chunk.length; if (size <= 8192) chunks.push(chunk); });
|
|
28
31
|
req.on('end', async function () {
|
|
29
32
|
try {
|
|
30
33
|
if (size > 8192) throw new Error('Request is too large.');
|
|
31
|
-
var token = null, workspace = null;
|
|
34
|
+
var token = null, workspace = null, name;
|
|
32
35
|
if (req.method === 'PUT') {
|
|
33
36
|
var body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
37
|
+
name = body.name;
|
|
38
|
+
if (match && (typeof name !== 'string' || !name.trim() || name.trim().length > 80)) throw new Error('Invalid connection name.');
|
|
39
|
+
if (match && !body.key) body.key = storage.key(id, connectionId);
|
|
34
40
|
if (typeof body.key !== 'string' || !body.key.trim() || body.key.length > 4096 || /[\r\n]/.test(body.key)) throw new Error('Enter a valid Linear API key.');
|
|
35
41
|
token = body.key.trim();
|
|
36
42
|
var data = await (ctx.query || api.query)(token, 'query ClayConnection { organization { id name urlKey } }');
|
|
37
43
|
if (!data.organization || !data.organization.id) throw new Error('Linear workspace is unavailable.');
|
|
38
44
|
workspace = { id: data.organization.id, name: data.organization.name, slug: data.organization.urlKey };
|
|
39
45
|
}
|
|
40
|
-
if (pending.get(
|
|
41
|
-
json(res, 200, storage.save(id, token, workspace));
|
|
46
|
+
if (pending.get(pendingId) !== operation || owner(req) !== id || res.destroyed) throw new Error('Connection changed. Please try again.');
|
|
47
|
+
json(res, 200, storage.save(id, token, workspace, connectionId, name));
|
|
42
48
|
} catch (error) { if (!res.destroyed) json(res, 400, { error: 'Could not update Linear connection. Check your API key and try again.' }); }
|
|
43
|
-
finally { if (pending.get(
|
|
49
|
+
finally { if (pending.get(pendingId) === operation) pending.delete(pendingId); }
|
|
44
50
|
});
|
|
45
|
-
req.on('error', function () { if (pending.get(
|
|
51
|
+
req.on('error', function () { if (pending.get(pendingId) === operation) pending.delete(pendingId); });
|
|
46
52
|
return true;
|
|
47
53
|
}
|
|
48
54
|
return { handleRequest: handleRequest, storage: storage };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
var fs = require('fs');
|
|
2
|
+
var path = require('path');
|
|
3
|
+
|
|
4
|
+
function attachNotificationSettings(ctx) {
|
|
5
|
+
var defaultPath = path.join(ctx.CONFIG_DIR, 'notification-preferences.json');
|
|
6
|
+
function reply(res, code, data) {
|
|
7
|
+
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
8
|
+
res.end(JSON.stringify(data));
|
|
9
|
+
}
|
|
10
|
+
function identity(req) {
|
|
11
|
+
if (!ctx.isRequestAuthed(req)) return null;
|
|
12
|
+
if (!ctx.users.isMultiUser()) return { id: 'default' };
|
|
13
|
+
return ctx.getMultiUserFromReq(req);
|
|
14
|
+
}
|
|
15
|
+
function read(user) {
|
|
16
|
+
var saved;
|
|
17
|
+
if (ctx.users.isMultiUser()) saved = user.notificationPreferences;
|
|
18
|
+
else {
|
|
19
|
+
try { saved = JSON.parse(fs.readFileSync(defaultPath, 'utf8')); }
|
|
20
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
21
|
+
}
|
|
22
|
+
return { alert: !saved || saved.alert !== false, sound: !saved || saved.sound !== false, configured: !!saved };
|
|
23
|
+
}
|
|
24
|
+
function save(user, values) {
|
|
25
|
+
if (ctx.users.isMultiUser()) {
|
|
26
|
+
var data = ctx.users.loadUsers();
|
|
27
|
+
var current = data.users.find(function (entry) { return entry.id === user.id; });
|
|
28
|
+
if (!current) throw new Error('Account no longer available.');
|
|
29
|
+
current.notificationPreferences = values;
|
|
30
|
+
ctx.users.saveUsers(data);
|
|
31
|
+
} else {
|
|
32
|
+
fs.mkdirSync(ctx.CONFIG_DIR, { recursive: true });
|
|
33
|
+
fs.writeFileSync(defaultPath + '.tmp', JSON.stringify(values), { mode: 0o600 });
|
|
34
|
+
fs.renameSync(defaultPath + '.tmp', defaultPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function handleRequest(req, res, url) {
|
|
38
|
+
if (url !== '/api/user/notifications') return false;
|
|
39
|
+
var user = identity(req);
|
|
40
|
+
if (!user) { reply(res, 401, { error: 'Sign in to manage notifications.' }); return true; }
|
|
41
|
+
if (req.method === 'GET') {
|
|
42
|
+
try { reply(res, 200, read(user)); } catch (error) { reply(res, 500, { error: 'Could not read notification preferences.' }); }
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (req.method !== 'PUT') { reply(res, 405, { error: 'Method not allowed.' }); return true; }
|
|
46
|
+
if (req.headers['x-clay-settings'] !== '1' || (req.headers['sec-fetch-site'] && ['same-origin', 'none'].indexOf(req.headers['sec-fetch-site']) < 0)) {
|
|
47
|
+
reply(res, 403, { error: 'Save preferences from Clay.' }); return true;
|
|
48
|
+
}
|
|
49
|
+
var body = ''; var tooLarge = false;
|
|
50
|
+
req.setEncoding('utf8');
|
|
51
|
+
req.on('data', function (chunk) { body += chunk; if (body.length > 4096 && !tooLarge) { tooLarge = true; reply(res, 413, { error: 'Request too large.' }); } });
|
|
52
|
+
req.on('end', function () {
|
|
53
|
+
if (tooLarge) return;
|
|
54
|
+
user = identity(req);
|
|
55
|
+
if (!user) { reply(res, 401, { error: 'Sign in again.' }); return; }
|
|
56
|
+
var values;
|
|
57
|
+
try { values = JSON.parse(body); } catch (error) { reply(res, 400, { error: 'Invalid JSON.' }); return; }
|
|
58
|
+
if (!values || typeof values.alert !== 'boolean' || typeof values.sound !== 'boolean') { reply(res, 400, { error: 'Alert and sound must be booleans.' }); return; }
|
|
59
|
+
values = { alert: values.alert, sound: values.sound };
|
|
60
|
+
try { save(user, values); reply(res, 200, values); }
|
|
61
|
+
catch (error) { reply(res, 500, { error: 'Could not save notification preferences. Your draft is retained.' }); }
|
|
62
|
+
});
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return { handleRequest: handleRequest };
|
|
66
|
+
}
|
|
67
|
+
module.exports = { attachNotificationSettings: attachNotificationSettings };
|
package/lib/server-settings.js
CHANGED
|
@@ -12,9 +12,13 @@ function attachSettings(ctx) {
|
|
|
12
12
|
var CONFIG_DIR = ctx.CONFIG_DIR;
|
|
13
13
|
|
|
14
14
|
var profilePath = path.join(CONFIG_DIR, "profile.json");
|
|
15
|
+
var appearanceSettings = require('./server-appearance-settings').attachAppearanceSettings(ctx);
|
|
16
|
+
var notificationSettings = require('./server-notification-settings').attachNotificationSettings(ctx);
|
|
15
17
|
var experimentalSettings = attachExperimentalSettings(ctx);
|
|
16
18
|
|
|
17
19
|
function handleRequest(req, res, fullUrl) {
|
|
20
|
+
if (appearanceSettings.handleRequest(req, res, fullUrl)) return true;
|
|
21
|
+
if (notificationSettings.handleRequest(req, res, fullUrl)) return true;
|
|
18
22
|
if (experimentalSettings.handleRequest(req, res, fullUrl)) return true;
|
|
19
23
|
// Render historical avatar styles locally so profile seeds never leave Clay.
|
|
20
24
|
if (req.method === "GET" && fullUrl === "/api/generated-avatar") {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
var scanStorage = require('./storage-usage').scanStorage;
|
|
2
|
+
|
|
3
|
+
function attachStorage(ctx) {
|
|
4
|
+
var pending = null;
|
|
5
|
+
var cached = null;
|
|
6
|
+
var progress = null;
|
|
7
|
+
var expires = 0;
|
|
8
|
+
var failed = false;
|
|
9
|
+
function reply(res, status, data) {
|
|
10
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
11
|
+
res.end(JSON.stringify(data));
|
|
12
|
+
}
|
|
13
|
+
function authorized(req, res) {
|
|
14
|
+
if (!ctx.isRequestAuthed(req)) { reply(res, 401, { error: 'Authentication required.' }); return false; }
|
|
15
|
+
var user = ctx.getMultiUserFromReq(req);
|
|
16
|
+
if (ctx.users.isMultiUser() && (!user || user.role !== 'admin')) {
|
|
17
|
+
reply(res, 403, { error: 'Only server administrators can view disk usage.' }); return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
function handleRequest(req, res, url) {
|
|
22
|
+
if (url !== '/api/server/storage') return false;
|
|
23
|
+
if (!authorized(req, res)) return true;
|
|
24
|
+
if (req.method !== 'GET') { reply(res, 405, { error: 'Method not allowed.' }); return true; }
|
|
25
|
+
if (failed && Date.now() < expires) { reply(res, 500, { error: 'Disk usage could not be measured. Try again in a few seconds.' }); return true; }
|
|
26
|
+
if (cached && Date.now() < expires) { reply(res, 200, cached); return true; }
|
|
27
|
+
if (!pending) {
|
|
28
|
+
progress = null; failed = false;
|
|
29
|
+
var users = ctx.users.getAllUsers();
|
|
30
|
+
var projects = ctx.getProjects ? ctx.getProjects() : [];
|
|
31
|
+
if (!ctx.users.isMultiUser() && users.length <= 1) {
|
|
32
|
+
if (!users.length) users = [{ id: 'local', username: 'Local user' }];
|
|
33
|
+
projects = projects.map(function (project) { return Object.assign({}, project, { projectOwnerId: project.projectOwnerId || users[0].id }); });
|
|
34
|
+
}
|
|
35
|
+
pending = (ctx.scanStorage || scanStorage)({ configDir: ctx.configDir, platform: process.platform,
|
|
36
|
+
attribution: ctx.osUsers && ctx.users.isMultiUser() && process.platform === 'linux' ? 'filesystem' : 'project',
|
|
37
|
+
users: users, projects: projects, onProgress: function (data) { progress = data; },
|
|
38
|
+
projectPaths: ctx.getProjectPaths ? ctx.getProjectPaths() : [] }).then(function (result) {
|
|
39
|
+
cached = result; expires = Date.now() + 30000; return result;
|
|
40
|
+
}).catch(function (error) { failed = true; expires = Date.now() + 5000; throw error; }).finally(function () { pending = null; });
|
|
41
|
+
}
|
|
42
|
+
var timer;
|
|
43
|
+
Promise.race([pending, new Promise(function (resolve) {
|
|
44
|
+
timer = setTimeout(function () { resolve(progress || { scanning: true }); }, 750);
|
|
45
|
+
})]).then(function (result) {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
if (!res.destroyed && authorized(req, res)) reply(res, result.scanning ? 202 : 200, result);
|
|
48
|
+
}).catch(function () {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
if (!res.destroyed) reply(res, 500, { error: 'Disk usage could not be measured. Try refreshing.' });
|
|
51
|
+
});
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return { handleRequest: handleRequest };
|
|
55
|
+
}
|
|
56
|
+
module.exports = { attachStorage: attachStorage };
|
package/lib/server.js
CHANGED
|
@@ -188,7 +188,6 @@ function createServer(opts) {
|
|
|
188
188
|
var onGetDaemonConfig = opts.onGetDaemonConfig || null;
|
|
189
189
|
var onSetPin = opts.onSetPin || null;
|
|
190
190
|
var onSetKeepAwake = opts.onSetKeepAwake || null;
|
|
191
|
-
var onSetInheritGroups = opts.onSetInheritGroups || null;
|
|
192
191
|
var onSetImageRetention = opts.onSetImageRetention || null;
|
|
193
192
|
var onShutdown = opts.onShutdown || null;
|
|
194
193
|
var onRestart = opts.onRestart || null;
|
|
@@ -473,9 +472,14 @@ function createServer(opts) {
|
|
|
473
472
|
users: users, getMultiUserFromReq: getMultiUserFromReq,
|
|
474
473
|
isRequestAuthed: isRequestAuthed, opts: opts, getConfiguredOrigins: getConfiguredOrigins,
|
|
475
474
|
});
|
|
475
|
+
var storageSettings = require('./server-storage').attachStorage({
|
|
476
|
+
users: users, getMultiUserFromReq: getMultiUserFromReq, isRequestAuthed: isRequestAuthed,
|
|
477
|
+
configDir: CONFIG_DIR, osUsers: osUsers, getProjects: getProjects,
|
|
478
|
+
});
|
|
476
479
|
var logRejectedOrigin = createOriginRejectionLogger({ log: opts.onOriginRejectedLog });
|
|
477
480
|
|
|
478
481
|
var settings = serverSettings.attachSettings({
|
|
482
|
+
isRequestAuthed: isRequestAuthed,
|
|
479
483
|
users: users,
|
|
480
484
|
mates: mates,
|
|
481
485
|
getMultiUserFromReq: getMultiUserFromReq,
|
|
@@ -815,6 +819,7 @@ function createServer(opts) {
|
|
|
815
819
|
if (linear.handleRequest(req, res, fullUrl)) return;
|
|
816
820
|
if (speech.handleRequest(req, res, fullUrl)) return;
|
|
817
821
|
if (networkSettings.handleRequest(req, res, fullUrl)) return;
|
|
822
|
+
if (storageSettings.handleRequest(req, res, fullUrl)) return;
|
|
818
823
|
if (settings.handleRequest(req, res, fullUrl)) return;
|
|
819
824
|
|
|
820
825
|
// --- Admin API endpoints (multi-user mode only) ---
|
|
@@ -1516,10 +1521,13 @@ function createServer(opts) {
|
|
|
1516
1521
|
},
|
|
1517
1522
|
onGetProjectMcpServers: onGetProjectMcpServers,
|
|
1518
1523
|
onSetProjectMcpServers: onSetProjectMcpServers,
|
|
1524
|
+
onGetSharedEnv: opts.onGetSharedEnv,
|
|
1525
|
+
onSetSharedEnv: opts.onSetSharedEnv,
|
|
1526
|
+
onGetProjectEnv: opts.onGetProjectEnv,
|
|
1527
|
+
onSetProjectEnv: opts.onSetProjectEnv,
|
|
1519
1528
|
onGetDaemonConfig: onGetDaemonConfig,
|
|
1520
1529
|
onSetPin: onSetPin,
|
|
1521
1530
|
onSetKeepAwake: onSetKeepAwake,
|
|
1522
|
-
onSetInheritGroups: onSetInheritGroups,
|
|
1523
1531
|
onSetImageRetention: onSetImageRetention,
|
|
1524
1532
|
onSetUpdateChannel: onSetUpdateChannel,
|
|
1525
1533
|
updateChannel: onGetDaemonConfig ? (onGetDaemonConfig().updateChannel || "stable") : "stable",
|
package/lib/sessions.js
CHANGED
|
@@ -444,9 +444,8 @@ function createSessionManager(opts) {
|
|
|
444
444
|
// Adopt orphaned CLI sessions from ~/.claude/projects/<encoded-cwd>/ as
|
|
445
445
|
// Clay session records. After this runs the sidebar shows a single
|
|
446
446
|
// unified list of sessions regardless of whether they were born inside
|
|
447
|
-
// Clay or via the `claude` CLI directly.
|
|
448
|
-
//
|
|
449
|
-
// paths already exist for born-TUI sessions.
|
|
447
|
+
// Clay or via the `claude` CLI directly. Idle CLI sessions hydrate into
|
|
448
|
+
// Clay chat; already-running terminal runtimes keep their presentation.
|
|
450
449
|
//
|
|
451
450
|
// Adopted records are saved with mode='tui' because they originated in
|
|
452
451
|
// the CLI, not via the SDK. The cross-mode click logic in
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Saved project records are authoritative; filesystem availability only controls
|
|
2
|
+
// runtime registration. Recent-project entries must never restore access grants.
|
|
3
|
+
function buildStartupProjects(config, recent, options) {
|
|
4
|
+
var projects = [];
|
|
5
|
+
var paths = new Set();
|
|
6
|
+
var slugs = [];
|
|
7
|
+
var removed = new Set((config && config.removedProjects || []).map(function (p) { return p.path; }));
|
|
8
|
+
(config && config.projects || []).forEach(function (p) {
|
|
9
|
+
if (paths.has(p.path)) return;
|
|
10
|
+
projects.push(Object.assign({}, p));
|
|
11
|
+
paths.add(p.path);
|
|
12
|
+
slugs.push(p.slug);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function add(project) {
|
|
16
|
+
if (paths.has(project.path) || removed.has(project.path)) return;
|
|
17
|
+
if (!options.existsSync(project.path)) return;
|
|
18
|
+
var slug = options.generateSlug(project.path, slugs);
|
|
19
|
+
projects.push({
|
|
20
|
+
path: project.path,
|
|
21
|
+
slug: slug,
|
|
22
|
+
title: project.title || undefined,
|
|
23
|
+
icon: project.icon || undefined,
|
|
24
|
+
addedAt: project.addedAt || Date.now(),
|
|
25
|
+
});
|
|
26
|
+
paths.add(project.path);
|
|
27
|
+
slugs.push(slug);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (options.addCwd) {
|
|
31
|
+
var cwdRecent = (recent || []).find(function (p) { return p.path === options.cwd; });
|
|
32
|
+
add(cwdRecent || { path: options.cwd });
|
|
33
|
+
}
|
|
34
|
+
(recent || []).forEach(function (p) {
|
|
35
|
+
if (p.path !== options.cwd) add(p);
|
|
36
|
+
});
|
|
37
|
+
return projects;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { buildStartupProjects: buildStartupProjects };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
var fs = require('node:fs/promises');
|
|
2
|
+
var path = require('node:path');
|
|
3
|
+
var execFile = require('node:child_process').execFile;
|
|
4
|
+
|
|
5
|
+
function resolveIdentity(username) {
|
|
6
|
+
return new Promise(function (resolve) {
|
|
7
|
+
if (!/^[a-z_][a-z0-9_-]*[$]?$/.test(username || '')) return resolve(null);
|
|
8
|
+
execFile('getent', ['passwd', username], { timeout: 3000, maxBuffer: 8192 }, function (error, output) {
|
|
9
|
+
if (error) return resolve(null);
|
|
10
|
+
var parts = output.trim().split(':');
|
|
11
|
+
var uid = Number(parts[2]);
|
|
12
|
+
resolve(parts.length >= 7 && /^\d+$/.test(parts[2]) && Number.isInteger(uid) && uid >= 0 && path.isAbsolute(parts[5]) ? { uid: uid, home: parts[5] } : null);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Read metadata only. Shared paths and hard links count once across all roots.
|
|
18
|
+
async function scanStorage(options) {
|
|
19
|
+
var started = Date.now();
|
|
20
|
+
var deadline = options.maxMs ? started + options.maxMs : Infinity;
|
|
21
|
+
var maxEntries = options.maxEntries || Infinity;
|
|
22
|
+
var users = options.users || [];
|
|
23
|
+
var projectMode = options.attribution === "project";
|
|
24
|
+
var projects = options.projects || [];
|
|
25
|
+
var identities = await Promise.all(users.map(function (user) {
|
|
26
|
+
return !projectMode && options.platform === 'linux' && user.linuxUser ? (options.resolveIdentity || resolveIdentity)(user.linuxUser) : null;
|
|
27
|
+
}));
|
|
28
|
+
var rows = users.map(function (user, index) {
|
|
29
|
+
return { id: user.id, name: user.displayName || user.username, linuxUser: user.linuxUser || null, mapped: projectMode || !!identities[index], projects: 0, bytes: 0, files: 0 };
|
|
30
|
+
});
|
|
31
|
+
var owners = new Map();
|
|
32
|
+
identities.forEach(function (identity, index) {
|
|
33
|
+
if (!identity) return;
|
|
34
|
+
// A reused OS identity cannot be split accurately between Clay accounts.
|
|
35
|
+
owners.set(identity.uid, owners.has(identity.uid) ? null : rows[index]);
|
|
36
|
+
});
|
|
37
|
+
identities.forEach(function (identity, index) {
|
|
38
|
+
if (identity && !owners.get(identity.uid)) rows[index].mapped = false;
|
|
39
|
+
});
|
|
40
|
+
var projectRoots = new Map();
|
|
41
|
+
var projectErrors = 0;
|
|
42
|
+
for (var project of projects) {
|
|
43
|
+
try {
|
|
44
|
+
var projectPath = await fs.realpath(project.path);
|
|
45
|
+
var row = rows.find(function (item) { return item.id === project.projectOwnerId; }) || null;
|
|
46
|
+
if (row) row.projects++;
|
|
47
|
+
if (projectRoots.has(projectPath) && projectRoots.get(projectPath) !== row) row = null;
|
|
48
|
+
projectRoots.set(projectPath, row);
|
|
49
|
+
} catch (error) { projectErrors++; }
|
|
50
|
+
}
|
|
51
|
+
var orderedProjects = Array.from(projectRoots.keys()).sort(function (a, b) { return b.length - a.length; });
|
|
52
|
+
function projectOwner(filePath) {
|
|
53
|
+
var root = orderedProjects.find(function (candidate) { return filePath === candidate || filePath.indexOf(candidate + path.sep) === 0; });
|
|
54
|
+
return root ? projectRoots.get(root) : null;
|
|
55
|
+
}
|
|
56
|
+
var roots = orderedProjects.concat(options.projectPaths || []);
|
|
57
|
+
identities.forEach(function (identity) { if (identity) roots.push(identity.home); });
|
|
58
|
+
roots.push(options.configDir);
|
|
59
|
+
var report = { attribution: projectMode ? "project" : "filesystem", users: rows, totalBytes: 0, totalFiles: 0, unattributedBytes: 0, unattributedFiles: 0,
|
|
60
|
+
partial: false, errors: projectErrors + users.filter(function (user, index) { return !projectMode && options.platform === 'linux' && user.linuxUser && !identities[index]; }).length, skippedLinks: 0, skippedMounts: 0, scannedEntries: 0, roots: 0, platform: options.platform };
|
|
61
|
+
var lastProgress = 0;
|
|
62
|
+
function progress() {
|
|
63
|
+
if (!options.onProgress || Date.now() - lastProgress < 500) return;
|
|
64
|
+
lastProgress = Date.now();
|
|
65
|
+
options.onProgress(Object.assign({}, report, { scanning: true, checkedAt: new Date().toISOString(),
|
|
66
|
+
users: rows.map(function (row) { return Object.assign({}, row); }) }));
|
|
67
|
+
}
|
|
68
|
+
progress();
|
|
69
|
+
var seen = new Map();
|
|
70
|
+
var rootPaths = new Set();
|
|
71
|
+
for (var root of roots) {
|
|
72
|
+
if (typeof root !== 'string' || !path.isAbsolute(root)) { report.errors++; continue; }
|
|
73
|
+
try {
|
|
74
|
+
root = await fs.realpath(root);
|
|
75
|
+
// Never turn a misconfigured home/project into a whole-host scan.
|
|
76
|
+
if (root === path.parse(root).root) { report.errors++; continue; }
|
|
77
|
+
if (rootPaths.has(root)) continue;
|
|
78
|
+
rootPaths.add(root);
|
|
79
|
+
var rootStat = await fs.lstat(root);
|
|
80
|
+
report.roots++;
|
|
81
|
+
await walk(root, rootStat.dev);
|
|
82
|
+
} catch (error) { report.errors++; }
|
|
83
|
+
if (report.partial) break;
|
|
84
|
+
}
|
|
85
|
+
report.partial = report.partial || report.errors > 0 || report.skippedMounts > 0;
|
|
86
|
+
report.scanning = false;
|
|
87
|
+
report.checkedAt = new Date().toISOString();
|
|
88
|
+
return report;
|
|
89
|
+
|
|
90
|
+
async function walk(filePath, device) {
|
|
91
|
+
if (Date.now() >= deadline || report.scannedEntries >= maxEntries) { report.partial = true; return; }
|
|
92
|
+
var stat;
|
|
93
|
+
try { stat = await fs.lstat(filePath); } catch (error) { report.errors++; return; }
|
|
94
|
+
if (stat.isSymbolicLink()) { report.skippedLinks++; return; }
|
|
95
|
+
if (stat.dev !== device) { report.skippedMounts++; return; }
|
|
96
|
+
var key = stat.dev + ':' + stat.ino;
|
|
97
|
+
var owner = projectMode ? projectOwner(filePath) : owners.get(stat.uid);
|
|
98
|
+
if (seen.has(key)) {
|
|
99
|
+
var previous = seen.get(key);
|
|
100
|
+
if (projectMode && stat.isFile() && previous.owner && previous.owner !== owner) {
|
|
101
|
+
previous.owner.bytes -= previous.bytes; previous.owner.files--;
|
|
102
|
+
report.unattributedBytes += previous.bytes; report.unattributedFiles++;
|
|
103
|
+
previous.owner = null;
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
report.scannedEntries++;
|
|
108
|
+
if (!stat.isDirectory() && !stat.isFile()) return;
|
|
109
|
+
var bytes = typeof stat.blocks === 'number' ? stat.blocks * 512 : stat.size;
|
|
110
|
+
// Only directories and multiply-linked files need identity deduplication.
|
|
111
|
+
if (stat.isDirectory() || stat.nlink > 1) seen.set(key, { owner: owner, bytes: bytes });
|
|
112
|
+
report.totalBytes += bytes;
|
|
113
|
+
if (stat.isFile()) report.totalFiles++;
|
|
114
|
+
if (owner) { owner.bytes += bytes; if (stat.isFile()) owner.files++; }
|
|
115
|
+
else { report.unattributedBytes += bytes; if (stat.isFile()) report.unattributedFiles++; }
|
|
116
|
+
progress();
|
|
117
|
+
if (!stat.isDirectory()) return;
|
|
118
|
+
try {
|
|
119
|
+
var directory = await fs.opendir(filePath);
|
|
120
|
+
for await (var entry of directory) {
|
|
121
|
+
await walk(path.join(filePath, entry.name), device);
|
|
122
|
+
if (report.partial) break;
|
|
123
|
+
}
|
|
124
|
+
} catch (error) { report.errors++; }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
module.exports = { scanStorage: scanStorage };
|