clay-server 5.1.0-beta.2 → 5.1.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.
Files changed (101) hide show
  1. package/bin/cli.js +9 -91
  2. package/lib/builtin-mates.js +7 -7
  3. package/lib/daemon-settings.js +11 -0
  4. package/lib/daemon.js +8 -37
  5. package/lib/linear-store.js +35 -8
  6. package/lib/mate-identity-file.js +39 -0
  7. package/lib/mate-knowledge-sync.js +1 -1
  8. package/lib/mate-ready-creation.js +2 -1
  9. package/lib/mates-identity.js +4 -4
  10. package/lib/mates-prompts.js +4 -3
  11. package/lib/mates.js +12 -11
  12. package/lib/os-users.js +5 -16
  13. package/lib/project-connection.js +0 -7
  14. package/lib/project-debate.js +3 -2
  15. package/lib/project-filesystem.js +21 -39
  16. package/lib/project-mate-interaction.js +7 -6
  17. package/lib/project-server-settings.js +31 -0
  18. package/lib/project-sessions.js +57 -184
  19. package/lib/project.js +3 -3
  20. package/lib/public/app.js +6 -37
  21. package/lib/public/css/input.css +3 -1
  22. package/lib/public/css/menus.css +3 -0
  23. package/lib/public/css/overlays.css +7 -0
  24. package/lib/public/css/project-settings-modal.css +56 -0
  25. package/lib/public/css/server-settings-system.css +125 -0
  26. package/lib/public/css/session-github.css +25 -16
  27. package/lib/public/css/settings-ui.css +247 -22
  28. package/lib/public/css/shared-browser.css +7 -3
  29. package/lib/public/css/title-bar.css +5 -1
  30. package/lib/public/index.html +173 -447
  31. package/lib/public/modules/account-island.js +9 -7
  32. package/lib/public/modules/admin-settings-email.js +197 -0
  33. package/lib/public/modules/admin-settings-invitations.js +183 -0
  34. package/lib/public/modules/admin-settings-projects.js +198 -0
  35. package/lib/public/modules/admin-settings-ui.js +100 -0
  36. package/lib/public/modules/admin-settings-users.js +331 -0
  37. package/lib/public/modules/admin.js +19 -949
  38. package/lib/public/modules/app-dm.js +0 -2
  39. package/lib/public/modules/app-messages.js +13 -52
  40. package/lib/public/modules/app-rate-limit.js +70 -12
  41. package/lib/public/modules/avatar.js +6 -1
  42. package/lib/public/modules/context-sources.js +27 -18
  43. package/lib/public/modules/default-ai.js +14 -7
  44. package/lib/public/modules/header-tui-font.js +10 -20
  45. package/lib/public/modules/home-mate-avatar-editor.js +2 -2
  46. package/lib/public/modules/linear-settings.js +3 -2
  47. package/lib/public/modules/markdown.js +5 -6
  48. package/lib/public/modules/notification-settings.js +81 -0
  49. package/lib/public/modules/notifications.js +44 -28
  50. package/lib/public/modules/personal-settings-dialog.js +51 -0
  51. package/lib/public/modules/profile.js +15 -6
  52. package/lib/public/modules/project-settings-environment.js +106 -0
  53. package/lib/public/modules/project-settings.js +72 -628
  54. package/lib/public/modules/server-settings-controls.js +316 -0
  55. package/lib/public/modules/server-settings-environment.js +194 -0
  56. package/lib/public/modules/server-settings-navigation.js +39 -0
  57. package/lib/public/modules/server-settings-state.js +54 -0
  58. package/lib/public/modules/server-settings.js +116 -788
  59. package/lib/public/modules/server-storage-settings.js +84 -0
  60. package/lib/public/modules/session-github.js +2 -1
  61. package/lib/public/modules/session-presence-ui.js +1 -1
  62. package/lib/public/modules/settings-env-values.js +34 -0
  63. package/lib/public/modules/settings-select.js +121 -0
  64. package/lib/public/modules/shared-browser.js +22 -12
  65. package/lib/public/modules/stt-picker.js +14 -5
  66. package/lib/public/modules/terminal-prefs.js +22 -0
  67. package/lib/public/modules/theme.js +88 -52
  68. package/lib/public/modules/user-settings-account.js +318 -0
  69. package/lib/public/modules/user-settings-appearance.js +55 -0
  70. package/lib/public/modules/user-settings-editors.js +24 -0
  71. package/lib/public/modules/user-settings-integrations.js +104 -0
  72. package/lib/public/modules/user-settings-layout.js +80 -0
  73. package/lib/public/modules/user-settings-preferences.js +48 -0
  74. package/lib/public/modules/user-settings-profile.js +53 -0
  75. package/lib/public/modules/user-settings-themes.js +38 -0
  76. package/lib/public/modules/user-settings-voice.js +75 -0
  77. package/lib/public/modules/user-settings.js +53 -480
  78. package/lib/public/style.css +4 -0
  79. package/lib/sdk-message-processor.js +16 -4
  80. package/lib/sdk-quota-snapshot.js +37 -0
  81. package/lib/server-appearance-settings.js +71 -0
  82. package/lib/server-linear.js +14 -8
  83. package/lib/server-mate-instructions.js +2 -2
  84. package/lib/server-mates.js +2 -1
  85. package/lib/server-notification-settings.js +67 -0
  86. package/lib/server-settings.js +4 -0
  87. package/lib/server-storage.js +57 -0
  88. package/lib/server.js +10 -2
  89. package/lib/sessions.js +3 -4
  90. package/lib/startup-projects.js +40 -0
  91. package/lib/storage-native-walk.js +85 -0
  92. package/lib/storage-usage.js +144 -0
  93. package/lib/users-preferences.js +0 -74
  94. package/lib/users.js +0 -4
  95. package/lib/ws-schema.js +2 -4
  96. package/lib/yoke/acp-process-manager.js +2 -2
  97. package/lib/yoke/adapters/codex.js +12 -52
  98. package/lib/yoke/codex-app-server.js +2 -2
  99. package/lib/yoke/codex-model-catalog.js +52 -0
  100. package/lib/yoke/codex-rate-limits.js +26 -0
  101. 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
- // Detect "Not logged in" scenario early for the check below
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
- // Detect "Not logged in / Please run /login" from SDK.
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 || false,
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 };
@@ -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
- if (url !== '/api/linear/connection') return false;
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(id, operation);
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(id) !== operation || owner(req) !== id || res.destroyed) throw new Error('Connection changed. Please try again.');
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(id) === operation) pending.delete(id); }
49
+ finally { if (pending.get(pendingId) === operation) pending.delete(pendingId); }
44
50
  });
45
- req.on('error', function () { if (pending.get(id) === operation) pending.delete(id); });
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 };
@@ -1,6 +1,6 @@
1
+ var resolveMateIdentityFile = require('./mate-identity-file').resolveMateIdentityFile;
1
2
  // Owner-scoped identity editing; generated instruction sections stay intact.
2
3
  var fs = require('fs');
3
- var path = require('path');
4
4
  var crypto = require('crypto');
5
5
 
6
6
  function revision(content) { return crypto.createHash('sha256').update(content).digest('hex'); }
@@ -11,7 +11,7 @@ function handleMateInstructions(mates, ws, msg, userId) {
11
11
  var ctx = mates.buildMateCtx(userId);
12
12
  if (typeof msg.mateId !== 'string' || !mates.isMateIdFormat(msg.mateId) || !mates.getMate(ctx, msg.mateId)) throw new Error('Mate not found.');
13
13
  var directory = mates.getMateDir(ctx, msg.mateId);
14
- var file = path.join(directory, 'CLAUDE.md');
14
+ var file = resolveMateIdentityFile(directory);
15
15
  if (!fs.lstatSync(file).isFile()) throw new Error('Mate instructions are unavailable.');
16
16
  var content = fs.readFileSync(file, 'utf8');
17
17
  var identity = mates.extractIdentity(content);
@@ -1,3 +1,4 @@
1
+ var resolveMateIdentityFile = require('./mate-identity-file').resolveMateIdentityFile;
1
2
  var fs = require("fs");
2
3
  var path = require("path");
3
4
  var handleMateInstructions = require('./server-mate-instructions').handleMateInstructions;
@@ -32,7 +33,7 @@ function attachMates(ctx) {
32
33
  });
33
34
  for (var ri = 0; ri < allMates.length; ri++) {
34
35
  var mDir = mates.getMateDir(mateCtx, allMates[ri].id);
35
- var claudePath = path.join(mDir, "CLAUDE.md");
36
+ var claudePath = resolveMateIdentityFile(mDir);
36
37
  try {
37
38
  mates.enforceAllSections(claudePath, { ctx: mateCtx, mateId: allMates[ri].id, projects: projList });
38
39
  } catch (e) {}
@@ -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 };
@@ -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,57 @@
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.split('?')[0] !== '/api/server/storage') return false;
23
+ var refresh = new URL(url, 'http://localhost').searchParams.get('refresh') === '1';
24
+ if (!authorized(req, res)) return true;
25
+ if (req.method !== 'GET') { reply(res, 405, { error: 'Method not allowed.' }); return true; }
26
+ if (failed && Date.now() < expires) { reply(res, 500, { error: 'Disk usage could not be measured. Try again in a few seconds.' }); return true; }
27
+ if (!pending && !refresh && cached && Date.now() < expires) { reply(res, 200, cached); return true; }
28
+ if (!pending) {
29
+ progress = null; failed = false;
30
+ var users = ctx.users.getAllUsers();
31
+ var projects = ctx.getProjects ? ctx.getProjects() : [];
32
+ if (!ctx.users.isMultiUser() && users.length <= 1) {
33
+ if (!users.length) users = [{ id: 'local', username: 'Local user' }];
34
+ projects = projects.map(function (project) { return Object.assign({}, project, { projectOwnerId: project.projectOwnerId || users[0].id }); });
35
+ }
36
+ pending = (ctx.scanStorage || scanStorage)({ configDir: ctx.configDir, platform: process.platform,
37
+ attribution: ctx.osUsers && ctx.users.isMultiUser() && process.platform === 'linux' ? 'filesystem' : 'project',
38
+ users: users, projects: projects, onProgress: function (data) { progress = data; },
39
+ projectPaths: ctx.getProjectPaths ? ctx.getProjectPaths() : [] }).then(function (result) {
40
+ cached = result; expires = Date.now() + 300000; return result;
41
+ }).catch(function (error) { failed = true; expires = Date.now() + 5000; throw error; }).finally(function () { pending = null; });
42
+ }
43
+ var timer;
44
+ Promise.race([pending, new Promise(function (resolve) {
45
+ timer = setTimeout(function () { resolve(progress || { scanning: true }); }, 750);
46
+ })]).then(function (result) {
47
+ clearTimeout(timer);
48
+ if (!res.destroyed && authorized(req, res)) reply(res, result.scanning ? 202 : 200, result);
49
+ }).catch(function () {
50
+ clearTimeout(timer);
51
+ if (!res.destroyed) reply(res, 500, { error: 'Disk usage could not be measured. Try refreshing.' });
52
+ });
53
+ return true;
54
+ }
55
+ return { handleRequest: handleRequest };
56
+ }
57
+ 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. The user's claudeOpenMode pref
448
- // decides how each click renders (TUI respawn vs GUI hydration) - both
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
@@ -1410,7 +1409,7 @@ function createSessionManager(opts) {
1410
1409
  console.log("[session] Migrated " + migrated + " session title(s) to SDK format");
1411
1410
  }
1412
1411
  if (failed > 0) {
1413
- console.log("[session] Skipped " + failed + " session(s) (CLI session not found for current user)");
1412
+ console.log("[session] Could not sync " + failed + " session title(s) to the provider; local titles and history are unchanged");
1414
1413
  }
1415
1414
  }).catch(function(e) {
1416
1415
  console.error("[session] Migration chain failed:", e.message || e);
@@ -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,85 @@
1
+ var spawn = require('node:child_process').spawn;
2
+ var execFile = require('node:child_process').execFile;
3
+ var availability = null;
4
+ var command = process.platform === 'linux' ? 'find' : 'gfind';
5
+
6
+ function available() {
7
+ if (process.platform !== 'linux' && process.platform !== 'darwin') return Promise.resolve(false);
8
+ if (!availability) availability = new Promise(function (resolve) {
9
+ execFile(command, ['--version'], { timeout: 3000, maxBuffer: 4096 }, function (error, output) {
10
+ resolve(!error && output.indexOf('GNU findutils') !== -1);
11
+ });
12
+ });
13
+ return availability;
14
+ }
15
+
16
+ function literalPattern(value) {
17
+ return value.replace(/[\\*?\[\]]/g, '\\$&');
18
+ }
19
+
20
+ // NUL-delimited records keep tabs/newlines in filenames out of metadata parsing.
21
+ function parseRecord(record) {
22
+ var fields = [];
23
+ var start = 0;
24
+ for (var i = 0; i < 6; i++) {
25
+ var end = record.indexOf('\t', start);
26
+ if (end < 0) throw new Error('Invalid native storage metadata');
27
+ fields.push(record.slice(start, end));
28
+ start = end + 1;
29
+ }
30
+ if (!fields.slice(0, 5).every(function (value) { return /^\d+$/.test(value); }) || !/^[a-z?]$/.test(fields[5])) {
31
+ throw new Error('Invalid native storage metadata');
32
+ }
33
+ return { path: record.slice(start), stat: {
34
+ dev: Number(fields[0]), ino: Number(fields[1]), uid: Number(fields[2]), blocks: Number(fields[3]), nlink: Number(fields[4]),
35
+ isDirectory: function () { return fields[5] === 'd'; },
36
+ isFile: function () { return fields[5] === 'f'; },
37
+ isSymbolicLink: function () { return fields[5] === 'l'; }
38
+ } };
39
+ }
40
+
41
+ function walk(root, excluded, onEntry, maxMs) {
42
+ return new Promise(function (resolve) {
43
+ var args = ['-P', root, '-xdev'];
44
+ if (excluded.length) {
45
+ args.push('(');
46
+ excluded.forEach(function (entry, index) {
47
+ if (index) args.push('-o');
48
+ args.push('-path', literalPattern(entry));
49
+ });
50
+ args.push(')', '-prune', '-o');
51
+ }
52
+ args.push('-printf', '%D\t%i\t%U\t%b\t%n\t%y\t%p\\0');
53
+ var child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], env: Object.assign({}, process.env, { LC_ALL: 'C' }) });
54
+ var buffer = '';
55
+ var stopped = false;
56
+ var errors = 0;
57
+ var timer = null;
58
+ function stop() { stopped = true; child.kill('SIGKILL'); }
59
+ if (Number.isFinite(maxMs)) timer = setTimeout(stop, Math.max(1, maxMs));
60
+ child.stdout.setEncoding('utf8');
61
+ child.stdout.on('data', function (chunk) {
62
+ if (stopped) return;
63
+ buffer += chunk;
64
+ var end;
65
+ while (!stopped && (end = buffer.indexOf('\0')) !== -1) {
66
+ var record = buffer.slice(0, end);
67
+ buffer = buffer.slice(end + 1);
68
+ try {
69
+ var entry = parseRecord(record);
70
+ if (onEntry(entry.path, entry.stat) === false) stop();
71
+ } catch (error) { errors++; stop(); }
72
+ }
73
+ if (buffer.length > 1024 * 1024) { errors++; stop(); }
74
+ });
75
+ child.stderr.on('data', function () { errors = Math.max(1, errors); });
76
+ child.on('error', function () { errors++; });
77
+ child.on('close', function (code) {
78
+ clearTimeout(timer);
79
+ if (!stopped && (code !== 0 || buffer.length)) errors++;
80
+ resolve({ errors: errors, stopped: stopped });
81
+ });
82
+ });
83
+ }
84
+
85
+ module.exports = { available: available, walk: walk, parseRecord: parseRecord };