clay-server 2.23.1 → 2.24.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/lib/sdk-bridge.js CHANGED
@@ -189,6 +189,26 @@ function createSDKBridge(opts) {
189
189
  }
190
190
 
191
191
  function processSDKMessage(session, parsed) {
192
+ // Timing: log key SDK milestones relative to query start
193
+ if (session._queryStartTs) {
194
+ var _elapsed = Date.now() - session._queryStartTs;
195
+ if (parsed.type === "system" && parsed.subtype === "init") {
196
+ console.log("[PERF] processSDKMessage: system/init +" + _elapsed + "ms");
197
+ }
198
+ if (parsed.type === "stream_event" && parsed.event) {
199
+ if (parsed.event.type === "message_start") {
200
+ console.log("[PERF] processSDKMessage: message_start (API response begun) +" + _elapsed + "ms");
201
+ }
202
+ if (parsed.event.type === "content_block_delta" && !session._firstTextLogged) {
203
+ session._firstTextLogged = true;
204
+ console.log("[PERF] processSDKMessage: FIRST content_block_delta (visible text) +" + _elapsed + "ms");
205
+ }
206
+ }
207
+ if (parsed.type === "result") {
208
+ console.log("[PERF] processSDKMessage: result +" + _elapsed + "ms");
209
+ }
210
+ }
211
+
192
212
  // Extract session_id from any message that carries it
193
213
  if (parsed.session_id && !session.cliSessionId) {
194
214
  session.cliSessionId = parsed.session_id;
@@ -756,7 +776,9 @@ function createSDKBridge(opts) {
756
776
  });
757
777
 
758
778
  // Create Unix socket server
779
+ var spawnT0 = Date.now();
759
780
  worker.server = net.createServer(function(connection) {
781
+ console.log("[PERF] spawnWorker: socket connection accepted +" + (Date.now() - spawnT0) + "ms");
760
782
  worker.connection = connection;
761
783
  connection.on("data", function(chunk) {
762
784
  worker.buffer += chunk.toString();
@@ -767,6 +789,7 @@ function createSDKBridge(opts) {
767
789
  try {
768
790
  var msg = JSON.parse(lines[i]);
769
791
  if (msg.type === "ready") {
792
+ console.log("[PERF] spawnWorker: 'ready' IPC received +" + (Date.now() - spawnT0) + "ms");
770
793
  worker.ready = true;
771
794
  if (worker._readyResolve) {
772
795
  worker._readyResolve();
@@ -787,6 +810,7 @@ function createSDKBridge(opts) {
787
810
  });
788
811
 
789
812
  worker.server.listen(socketPath, function() {
813
+ console.log("[PERF] spawnWorker: socket listen ready +" + (Date.now() - spawnT0) + "ms");
790
814
  // Set socket permissions so the target user can connect
791
815
  try { fs.chmodSync(socketPath, 0o777); } catch (e) {}
792
816
 
@@ -933,125 +957,96 @@ function createSDKBridge(opts) {
933
957
  * Mirrors the in-process startQuery flow but delegates SDK execution to the worker.
934
958
  */
935
959
  async function startQueryViaWorker(session, text, images, linuxUser) {
960
+ var t0 = session._queryStartTs || Date.now();
961
+ function perf(label) { console.log("[PERF] sdk-bridge: " + label + " +" + (Date.now() - t0) + "ms"); }
962
+ perf("startQueryViaWorker entered");
963
+
936
964
  // Wait for the previous worker to fully exit before spawning a new one.
937
965
  // Without this, the new worker may try to resume the SDK session file
938
966
  // while the old worker is still flushing it to disk (800ms grace period),
939
967
  // causing "no conversation found" and losing all prior context.
940
968
  if (session._workerExitPromise) {
941
- console.log("[sdk-bridge] startQueryViaWorker: waiting for old worker exit, localId=" + session.localId);
969
+ perf("waiting for old worker exit");
942
970
  var exitWait = session._workerExitPromise;
943
971
  session._workerExitPromise = null;
944
972
  await Promise.race([
945
973
  exitWait,
946
974
  new Promise(function(resolve) { setTimeout(resolve, 3000); }),
947
975
  ]);
948
- console.log("[sdk-bridge] startQueryViaWorker: old worker exit wait done");
976
+ perf("old worker exit wait done");
949
977
  }
950
978
 
951
- // Ensure the linux user's .claude directories are writable.
952
- // The daemon runs as root, so any dirs it creates (mkdirSync) will be
953
- // root-owned. The SDK (running as the linux user) needs write access
954
- // to create session files, otherwise resume silently fails.
955
- if (linuxUser) {
979
+ // Reuse existing worker if alive, otherwise spawn a new one.
980
+ // Spawn FIRST so the worker starts booting while we do dir setup below.
981
+ var worker;
982
+ var reusingWorker = false;
983
+ if (session.worker && session.worker.ready && session.worker.process && !session.worker.process.killed) {
984
+ worker = session.worker;
985
+ reusingWorker = true;
986
+ // Clear old message handlers so they don't fire for the new query
987
+ worker.messageHandlers = [];
988
+ worker._queryEnded = false;
989
+ worker._abortSent = false;
990
+ perf("reusing existing worker pid=" + (worker.process ? worker.process.pid : "?"));
991
+ } else {
956
992
  try {
957
- var osUsersMod0 = require("./os-users");
958
- var linuxUserHome0 = osUsersMod0.getLinuxUserHome(linuxUser);
959
- var uid0 = osUsersMod0.getLinuxUserUid(linuxUser);
960
- if (uid0 != null) {
961
- var claudeDir = path.join(linuxUserHome0, ".claude");
962
- var projectSlug0 = (cwd || "").replace(/\//g, "-");
963
- var projDir = path.join(claudeDir, "projects", projectSlug0);
964
- // Create the project directory if missing and chown the whole tree
965
- if (!fs.existsSync(projDir)) {
966
- fs.mkdirSync(projDir, { recursive: true });
967
- try { require("child_process").execSync("chown -R " + uid0 + " " + JSON.stringify(claudeDir)); } catch (e) {}
968
- } else {
969
- // Fix existing directory if root-owned
970
- try {
971
- var pstat = fs.statSync(projDir);
972
- if (pstat.uid !== uid0) {
973
- require("child_process").execSync("chown " + uid0 + " " + JSON.stringify(projDir));
974
- }
975
- } catch (e) {}
976
- }
977
- }
993
+ perf("spawning new worker");
994
+ worker = spawnWorker(linuxUser);
995
+ perf("spawnWorker returned");
996
+ session.worker = worker;
978
997
  } catch (e) {
979
- console.log("[sdk-bridge] Dir ownership fix skipped:", e.message);
998
+ session.isProcessing = false;
999
+ onProcessingChanged();
1000
+ sendAndRecord(session, { type: "error", text: "Failed to spawn worker for " + linuxUser + ": " + (e.message || e) });
1001
+ sendAndRecord(session, { type: "done", code: 1 });
1002
+ sm.broadcastSessionList();
1003
+ return;
980
1004
  }
981
1005
  }
982
1006
 
983
- // Pre-copy CLI session file BEFORE spawning worker.
984
- // Must happen before spawn so execSync doesn't block the event loop
985
- // while worker is alive (which causes ready/exit race conditions).
986
- if (session.cliSessionId && linuxUser) {
1007
+ // Ensure the linux user's .claude project directory exists and is writable,
1008
+ // then pre-copy CLI session file if needed. This runs while the worker is
1009
+ // booting (readyPromise pending), so it adds no extra latency.
1010
+ perf("dir setup start");
1011
+ if (linuxUser) {
987
1012
  try {
988
1013
  var configMod = require("./config");
989
1014
  var osUsersMod = require("./os-users");
990
1015
  var originalHome = configMod.REAL_HOME || require("os").homedir();
991
1016
  var linuxUserHome = osUsersMod.getLinuxUserHome(linuxUser);
992
- if (originalHome !== linuxUserHome) {
1017
+ var uid = osUsersMod.getLinuxUserUid(linuxUser);
1018
+ if (originalHome !== linuxUserHome && uid != null) {
993
1019
  var projectSlug = (cwd || "").replace(/\//g, "-");
994
- var sessionFileName = session.cliSessionId + ".jsonl";
995
- var srcFile = path.join(originalHome, ".claude", "projects", projectSlug, sessionFileName);
996
1020
  var dstDir = path.join(linuxUserHome, ".claude", "projects", projectSlug);
997
- var dstFile = path.join(dstDir, sessionFileName);
998
- var uid = osUsersMod.getLinuxUserUid(linuxUser);
999
- // Ensure the projects directory is owned by the linux user so the
1000
- // SDK can create new session files. Without this, mkdirSync creates
1001
- // root-owned directories and the SDK silently fails to save sessions.
1021
+ // Create and chown the project directory once
1002
1022
  if (!fs.existsSync(dstDir)) {
1003
1023
  fs.mkdirSync(dstDir, { recursive: true });
1004
- if (uid != null) {
1005
- try { require("child_process").execSync("chown -R " + uid + " " + JSON.stringify(dstDir)); } catch (e2) {}
1006
- }
1024
+ try { require("child_process").execSync("chown -R " + uid + " " + JSON.stringify(path.join(linuxUserHome, ".claude"))); } catch (e2) {}
1007
1025
  } else {
1008
- // Fix ownership of existing directories created by root
1009
1026
  try {
1010
1027
  var dirStat = fs.statSync(dstDir);
1011
- if (uid != null && dirStat.uid !== uid) {
1028
+ if (dirStat.uid !== uid) {
1012
1029
  require("child_process").execSync("chown " + uid + " " + JSON.stringify(dstDir));
1013
1030
  }
1014
1031
  } catch (e2) {}
1015
1032
  }
1016
- if (fs.existsSync(srcFile) && !fs.existsSync(dstFile)) {
1017
- fs.copyFileSync(srcFile, dstFile);
1018
- if (uid != null) {
1033
+ // Pre-copy CLI session file so the worker can resume the conversation
1034
+ if (session.cliSessionId) {
1035
+ var sessionFileName = session.cliSessionId + ".jsonl";
1036
+ var srcFile = path.join(originalHome, ".claude", "projects", projectSlug, sessionFileName);
1037
+ var dstFile = path.join(dstDir, sessionFileName);
1038
+ if (fs.existsSync(srcFile) && !fs.existsSync(dstFile)) {
1039
+ fs.copyFileSync(srcFile, dstFile);
1019
1040
  try { require("child_process").execSync("chown " + uid + " " + JSON.stringify(dstFile)); } catch (e2) {}
1041
+ console.log("[sdk-bridge] Pre-copied CLI session " + session.cliSessionId + " to " + linuxUser);
1020
1042
  }
1021
- console.log("[sdk-bridge] Pre-copied CLI session " + session.cliSessionId + " to " + linuxUser);
1022
1043
  }
1023
1044
  }
1024
1045
  } catch (copyErr) {
1025
- console.log("[sdk-bridge] Session pre-copy skipped:", copyErr.message);
1026
- }
1027
- }
1028
-
1029
- // Reuse existing worker if alive, otherwise spawn a new one.
1030
- // Keeping the worker alive between queries lets the SDK maintain session
1031
- // state in memory, avoiding disk-based resume that fails on abort.
1032
- var worker;
1033
- var reusingWorker = false;
1034
- if (session.worker && session.worker.ready && session.worker.process && !session.worker.process.killed) {
1035
- worker = session.worker;
1036
- reusingWorker = true;
1037
- // Clear old message handlers so they don't fire for the new query
1038
- worker.messageHandlers = [];
1039
- worker._queryEnded = false;
1040
- worker._abortSent = false;
1041
- console.log("[sdk-bridge] Reusing existing worker pid=" + (worker.process ? worker.process.pid : "?"));
1042
- } else {
1043
- try {
1044
- worker = spawnWorker(linuxUser);
1045
- session.worker = worker;
1046
- } catch (e) {
1047
- session.isProcessing = false;
1048
- onProcessingChanged();
1049
- sendAndRecord(session, { type: "error", text: "Failed to spawn worker for " + linuxUser + ": " + (e.message || e) });
1050
- sendAndRecord(session, { type: "done", code: 1 });
1051
- sm.broadcastSessionList();
1052
- return;
1046
+ console.log("[sdk-bridge] Dir setup / session pre-copy skipped:", copyErr.message);
1053
1047
  }
1054
1048
  }
1049
+ perf("dir setup done");
1055
1050
 
1056
1051
  session.messageQueue = "worker"; // sentinel: messages go via worker IPC
1057
1052
  session.blocks = {};
@@ -1119,7 +1114,12 @@ function createSDKBridge(opts) {
1119
1114
  }
1120
1115
 
1121
1116
  // Set up message handler for worker events
1117
+ var firstEventLogged = false;
1122
1118
  worker.onMessage(function(msg) {
1119
+ if (!firstEventLogged && msg.type === "sdk_event") {
1120
+ firstEventLogged = true;
1121
+ perf("FIRST sdk_event received (type=" + (msg.event && msg.event.type || "?") + ")");
1122
+ }
1123
1123
  switch (msg.type) {
1124
1124
  case "sdk_event":
1125
1125
  processSDKMessage(session, msg.event);
@@ -1312,8 +1312,10 @@ function createSDKBridge(opts) {
1312
1312
 
1313
1313
  // Wait for worker to be ready, then send query
1314
1314
  if (!reusingWorker) {
1315
+ perf("awaiting readyPromise");
1315
1316
  try {
1316
1317
  await worker.readyPromise;
1318
+ perf("readyPromise resolved");
1317
1319
  } catch (e) {
1318
1320
  session.isProcessing = false;
1319
1321
  onProcessingChanged();
@@ -1325,6 +1327,7 @@ function createSDKBridge(opts) {
1325
1327
  }
1326
1328
  }
1327
1329
 
1330
+ perf("sending query_start to worker");
1328
1331
  worker.send({
1329
1332
  type: "query_start",
1330
1333
  prompt: initialMessage,
@@ -1332,7 +1335,9 @@ function createSDKBridge(opts) {
1332
1335
  singleTurn: !!session.singleTurn,
1333
1336
  originalHome: require("./config").REAL_HOME || null,
1334
1337
  projectPath: session.cwd || null,
1338
+ _perfT0: t0,
1335
1339
  });
1340
+ perf("query_start sent");
1336
1341
  }
1337
1342
 
1338
1343
  function cleanupSessionWorker(session, fromWorker) {
package/lib/sdk-worker.js CHANGED
@@ -4,8 +4,14 @@
4
4
  //
5
5
  // Usage: node sdk-worker.js <socket-path>
6
6
 
7
+ // Force IPv4-only for all child processes (including SDK CLI subprocess).
8
+ // Without this, Node 22+ happy eyeballs tries IPv6 first (10s timeout on
9
+ // servers without IPv6 outbound), causing massive cold-start delays.
10
+ process.env.NODE_OPTIONS = (process.env.NODE_OPTIONS || "") + " --dns-result-order=ipv4first --no-network-family-autoselection";
11
+
7
12
  // Early diagnostic — writes directly to fd 2 to ensure output even if pipes close fast
8
- try { require("fs").writeSync(2, "[sdk-worker] BOOT pid=" + process.pid + " uid=" + (typeof process.getuid === "function" ? process.getuid() : "?") + " argv=" + process.argv.slice(1).join(" ") + "\n"); } catch (e) {}
13
+ var _workerBootTs = Date.now();
14
+ try { require("fs").writeSync(2, "[sdk-worker] BOOT pid=" + process.pid + " uid=" + (typeof process.getuid === "function" ? process.getuid() : "?") + " argv=" + process.argv.slice(1).join(" ") + " bootTs=" + _workerBootTs + "\n"); } catch (e) {}
9
15
 
10
16
  var net = require("net");
11
17
  var crypto = require("crypto");
@@ -204,9 +210,16 @@ function handleElicitationResponse(msg) {
204
210
 
205
211
  // --- Query handling ---
206
212
  async function handleQueryStart(msg) {
213
+ var t0 = msg._perfT0 || Date.now();
214
+ var localT0 = Date.now();
215
+ function perf(label) { console.log("[PERF] sdk-worker: " + label + " +" + (Date.now() - t0) + "ms (local +" + (Date.now() - localT0) + "ms)"); }
216
+ perf("handleQueryStart entered");
217
+
207
218
  var sdk;
208
219
  try {
220
+ perf("loading SDK");
209
221
  sdk = await getSDK();
222
+ perf("SDK loaded");
210
223
  } catch (e) {
211
224
  sendToDaemon({ type: "query_error", error: "Failed to load SDK: " + (e.message || e), exitCode: null, stderr: null });
212
225
  return;
@@ -223,6 +236,39 @@ async function handleQueryStart(msg) {
223
236
  // Build query options (callbacks are local, everything else from daemon)
224
237
  var options = msg.options || {};
225
238
  options.abortController = abortController;
239
+ options.debug = true;
240
+ options.debugFile = "/tmp/clay-cli-debug-" + process.pid + ".log";
241
+ // Override CLI subprocess spawn to inject NODE_OPTIONS for IPv4-first DNS.
242
+ // The SDK constructs its own env for the CLI process, so worker env vars
243
+ // like NODE_OPTIONS are not inherited. We intercept the spawn to fix this.
244
+ options.spawnClaudeCodeProcess = function(spawnOpts) {
245
+ // Force IPv4-only at every level: preload script patches dns.lookup to
246
+ // only return IPv4, disables autoSelectFamily, and sets ipv4first order.
247
+ // This is needed because the CLI's Axios-based HTTP client ignores
248
+ // NODE_OPTIONS dns flags and still attempts IPv6 connections via its
249
+ // custom TLS agent, causing 5-10s timeouts on IPv6-less servers.
250
+ var preloadScript = require("path").join(__dirname, "ipv4-only.js");
251
+ var extraOpts = " --require " + JSON.stringify(preloadScript);
252
+ extraOpts += " --dns-result-order=ipv4first --no-network-family-autoselection";
253
+ spawnOpts.env.NODE_OPTIONS = (spawnOpts.env.NODE_OPTIONS || "") + extraOpts;
254
+ console.log("[sdk-worker] spawnClaudeCodeProcess called, command=" + spawnOpts.command);
255
+ var cp = require("child_process").spawn(spawnOpts.command, spawnOpts.args, {
256
+ cwd: spawnOpts.cwd,
257
+ env: spawnOpts.env,
258
+ stdio: ["pipe", "pipe", "pipe"],
259
+ });
260
+ // Capture ALL CLI stderr
261
+ if (cp.stderr) {
262
+ cp.stderr.on("data", function(chunk) {
263
+ var lines = chunk.toString().split("\n");
264
+ for (var li = 0; li < lines.length; li++) {
265
+ var line = lines[li].trim();
266
+ if (line) console.log("[CLI-STDERR] " + line.substring(0, 500));
267
+ }
268
+ });
269
+ }
270
+ return cp;
271
+ };
226
272
  options.canUseTool = function(toolName, input, toolOpts) {
227
273
  // AskUserQuestion is handled specially: we send it as a separate IPC type
228
274
  // so the daemon can use its own AskUserQuestion handling logic
@@ -249,11 +295,13 @@ async function handleQueryStart(msg) {
249
295
  return onElicitation(request, elicitOpts);
250
296
  };
251
297
 
298
+ perf("creating query instance");
252
299
  try {
253
300
  queryInstance = sdk.query({
254
301
  prompt: messageQueue,
255
302
  options: options,
256
303
  });
304
+ perf("query instance created");
257
305
  } catch (e) {
258
306
  sendToDaemon({ type: "query_error", error: "Failed to create query: " + (e.message || e), exitCode: null, stderr: null });
259
307
  queryInstance = null;
@@ -269,9 +317,41 @@ async function handleQueryStart(msg) {
269
317
 
270
318
  // Stream events to daemon
271
319
  try {
320
+ var firstEvent = true;
321
+ var firstText = true;
322
+ var eventCounts = {};
272
323
  for await (var event of queryInstance) {
324
+ var etype = (event && event.type || "?");
325
+ var esubtype = (event && event.subtype || "");
326
+ eventCounts[etype] = (eventCounts[etype] || 0) + 1;
327
+ if (firstEvent) {
328
+ perf("FIRST event from SDK (type=" + etype + " subtype=" + esubtype + ")");
329
+ firstEvent = false;
330
+ }
331
+ // Log every non-content event, and the first content/text event
332
+ if (etype !== "content_block_delta" && etype !== "content_block_start" && etype !== "content_block_stop") {
333
+ var extraInfo = "";
334
+ if (esubtype === "api_retry") {
335
+ // Dump full event to see all available fields
336
+ try {
337
+ var retryDump = JSON.stringify(event, function(k, v) {
338
+ if (typeof v === "string" && v.length > 200) return v.substring(0, 200) + "...[truncated]";
339
+ return v;
340
+ });
341
+ extraInfo = " FULL=" + retryDump;
342
+ } catch (je) {
343
+ extraInfo = " keys=" + Object.keys(event).join(",");
344
+ }
345
+ }
346
+ perf("SDK event #" + eventCounts[etype] + " type=" + etype + " subtype=" + esubtype + extraInfo);
347
+ }
348
+ if (firstText && (etype === "content_block_delta" || etype === "assistant" || (etype === "content_block_start"))) {
349
+ perf("FIRST TEXT/CONTENT event (type=" + etype + " subtype=" + esubtype + ")");
350
+ firstText = false;
351
+ }
273
352
  sendToDaemon({ type: "sdk_event", event: event });
274
353
  }
354
+ perf("all events streamed (counts=" + JSON.stringify(eventCounts) + "), sending query_done");
275
355
  sendToDaemon({ type: "query_done" });
276
356
  } catch (err) {
277
357
  var errMsg = err.message || String(err);
@@ -424,9 +504,9 @@ function gracefulExit(code) {
424
504
  var _keepAlive = setInterval(function() {}, 30000);
425
505
 
426
506
  // --- Connect to daemon socket ---
427
- try { require("fs").writeSync(2, "[sdk-worker] Connecting to socket: " + socketPath + "\n"); } catch (e) {}
507
+ try { require("fs").writeSync(2, "[sdk-worker] Connecting to socket: " + socketPath + " +" + (Date.now() - _workerBootTs) + "ms since boot\n"); } catch (e) {}
428
508
  conn = net.connect(socketPath, function() {
429
- try { require("fs").writeSync(2, "[sdk-worker] Connected, sending ready\n"); } catch (e) {}
509
+ try { require("fs").writeSync(2, "[sdk-worker] Connected, sending ready +" + (Date.now() - _workerBootTs) + "ms since boot\n"); } catch (e) {}
430
510
  sendToDaemon({ type: "ready" });
431
511
  });
432
512
 
package/lib/server.js CHANGED
@@ -1132,6 +1132,8 @@ function createServer(opts) {
1132
1132
  profile.userId = mu.id;
1133
1133
  profile.role = mu.role;
1134
1134
  profile.autoContinueOnRateLimit = !!mu.autoContinueOnRateLimit;
1135
+ profile.chatLayout = mu.chatLayout || "channel";
1136
+ profile.mateOnboardingShown = !!mu.mateOnboardingShown;
1135
1137
  res.writeHead(200, { "Content-Type": "application/json" });
1136
1138
  res.end(JSON.stringify(profile));
1137
1139
  return;
@@ -1147,10 +1149,12 @@ function createServer(opts) {
1147
1149
  if (saved.avatarSeed) profile.avatarSeed = saved.avatarSeed;
1148
1150
  if (saved.avatarCustom) profile.avatarCustom = saved.avatarCustom;
1149
1151
  } catch (e) { /* file doesn't exist yet */ }
1150
- // Single-user auto-continue from daemon config
1152
+ // Single-user settings from daemon config
1151
1153
  if (typeof opts.onGetDaemonConfig === "function") {
1152
1154
  var dc = opts.onGetDaemonConfig();
1153
1155
  profile.autoContinueOnRateLimit = !!dc.autoContinueOnRateLimit;
1156
+ profile.chatLayout = dc.chatLayout || "channel";
1157
+ profile.mateOnboardingShown = !!dc.mateOnboardingShown;
1154
1158
  }
1155
1159
  // Check if custom avatar file exists
1156
1160
  try {
@@ -1262,7 +1266,10 @@ function createServer(opts) {
1262
1266
  }
1263
1267
  }
1264
1268
  } catch (e) {}
1265
- fs.writeFileSync(path.join(avatarDir, filename), raw);
1269
+ var avatarFilePath = path.join(avatarDir, filename);
1270
+ fs.writeFileSync(avatarFilePath, raw);
1271
+ try { fs.chmodSync(avatarFilePath, 0o644); } catch (e) {}
1272
+ try { fs.chmodSync(avatarDir, 0o755); } catch (e) {}
1266
1273
  res.writeHead(200, { "Content-Type": "application/json" });
1267
1274
  res.end(JSON.stringify({ ok: true, avatar: "/api/avatar/" + userId + "?v=" + Date.now() }));
1268
1275
  });
@@ -1360,7 +1367,10 @@ function createServer(opts) {
1360
1367
  }
1361
1368
  }
1362
1369
  } catch (e) {}
1363
- fs.writeFileSync(path.join(avatarDir, filename), raw);
1370
+ var mateAvatarFilePath = path.join(avatarDir, filename);
1371
+ fs.writeFileSync(mateAvatarFilePath, raw);
1372
+ try { fs.chmodSync(mateAvatarFilePath, 0o644); } catch (e) {}
1373
+ try { fs.chmodSync(avatarDir, 0o755); } catch (e) {}
1364
1374
  var avatarPath = "/api/mate-avatar/" + mateIdFromUrl + "?v=" + Date.now();
1365
1375
  // Update mate profile with custom avatar URL
1366
1376
  var profile = mate.profile || {};
@@ -1483,6 +1493,68 @@ function createServer(opts) {
1483
1493
  return;
1484
1494
  }
1485
1495
 
1496
+ // PUT /api/user/chat-layout
1497
+ if (req.method === "PUT" && fullUrl === "/api/user/chat-layout") {
1498
+ var mu = getMultiUserFromReq(req);
1499
+ if (!mu) {
1500
+ // Single-user: save to daemon config
1501
+ var body = "";
1502
+ req.on("data", function (chunk) { body += chunk; });
1503
+ req.on("end", function () {
1504
+ try {
1505
+ var data = JSON.parse(body);
1506
+ var val = (data.layout === "bubble") ? "bubble" : "channel";
1507
+ if (typeof opts.onSetChatLayout === "function") {
1508
+ opts.onSetChatLayout(val);
1509
+ }
1510
+ res.writeHead(200, { "Content-Type": "application/json" });
1511
+ res.end(JSON.stringify({ ok: true, chatLayout: val }));
1512
+ } catch (e) {
1513
+ res.writeHead(400, { "Content-Type": "application/json" });
1514
+ res.end('{"error":"Invalid request"}');
1515
+ }
1516
+ });
1517
+ return;
1518
+ }
1519
+ var body = "";
1520
+ req.on("data", function (chunk) { body += chunk; });
1521
+ req.on("end", function () {
1522
+ try {
1523
+ var data = JSON.parse(body);
1524
+ var result = users.setChatLayout(mu.id, data.layout);
1525
+ if (result.error) {
1526
+ res.writeHead(400, { "Content-Type": "application/json" });
1527
+ res.end(JSON.stringify({ error: result.error }));
1528
+ return;
1529
+ }
1530
+ res.writeHead(200, { "Content-Type": "application/json" });
1531
+ res.end(JSON.stringify({ ok: true, chatLayout: result.chatLayout }));
1532
+ } catch (e) {
1533
+ res.writeHead(400, { "Content-Type": "application/json" });
1534
+ res.end('{"error":"Invalid request"}');
1535
+ }
1536
+ });
1537
+ return;
1538
+ }
1539
+
1540
+ // POST /api/user/mate-onboarded
1541
+ if (req.method === "POST" && fullUrl === "/api/user/mate-onboarded") {
1542
+ var mu = getMultiUserFromReq(req);
1543
+ if (!mu) {
1544
+ // Single-user: save to daemon config
1545
+ if (typeof opts.onSetMateOnboarded === "function") {
1546
+ opts.onSetMateOnboarded();
1547
+ }
1548
+ res.writeHead(200, { "Content-Type": "application/json" });
1549
+ res.end('{"ok":true}');
1550
+ } else {
1551
+ users.setMateOnboarded(mu.id);
1552
+ res.writeHead(200, { "Content-Type": "application/json" });
1553
+ res.end('{"ok":true}');
1554
+ }
1555
+ return;
1556
+ }
1557
+
1486
1558
  // GET /api/user/auto-continue
1487
1559
  if (req.method === "GET" && fullUrl === "/api/user/auto-continue") {
1488
1560
  var mu = getMultiUserFromReq(req);
@@ -2829,6 +2901,24 @@ function createServer(opts) {
2829
2901
  });
2830
2902
  return list;
2831
2903
  },
2904
+ getAllProjectSessions: function () {
2905
+ var allSessions = [];
2906
+ projects.forEach(function (pCtx, pSlug) {
2907
+ if (pSlug === slug) return; // skip self
2908
+ var status = pCtx.getStatus();
2909
+ if (status.isWorktree) return;
2910
+ var pSm = pCtx.getSessionManager();
2911
+ if (!pSm) return;
2912
+ var projectTitle = status.title || status.project || pSlug;
2913
+ pSm.sessions.forEach(function (s) {
2914
+ if (!s.hidden && s.history && s.history.length > 0) {
2915
+ s._projectTitle = projectTitle;
2916
+ allSessions.push(s);
2917
+ }
2918
+ });
2919
+ });
2920
+ return allSessions;
2921
+ },
2832
2922
  getHubSchedules: function () {
2833
2923
  var allSchedules = [];
2834
2924
  projects.forEach(function (ctx, s) {
@@ -412,12 +412,13 @@ function searchSessions(sessions, query, opts) {
412
412
  }
413
413
 
414
414
  /**
415
- * Unified mate search: digests + session history combined.
416
- * Returns merged, re-ranked results from both sources.
415
+ * Unified mate search: digests + session history + knowledge files combined.
416
+ * Returns merged, re-ranked results from all sources.
417
417
  * For global search (e.g. Ally), pass otherDigests array with paths and mate names.
418
- * @param {object} opts - { digestFilePath, otherDigests, sessions, query, maxResults, minScore }
418
+ * @param {object} opts - { digestFilePath, otherDigests, sessions, knowledgeFiles, query, maxResults, minScore }
419
419
  * otherDigests: [{ path: string, mateName: string }] - other mates' digest files
420
- * @returns {Array} [{ source: "digest"|"session", score, mateName, ... }]
420
+ * knowledgeFiles: [{ filePath: string, name: string, mateName: string }] - knowledge files to search
421
+ * @returns {Array} [{ source: "digest"|"session"|"knowledge", score, mateName, ... }]
421
422
  */
422
423
  function searchMate(opts) {
423
424
  if (!opts || !opts.query || !opts.query.trim()) return [];
@@ -447,9 +448,33 @@ function searchMate(opts) {
447
448
  }
448
449
  }
449
450
 
451
+ // Collect knowledge file docs
452
+ if (opts.knowledgeFiles && opts.knowledgeFiles.length > 0) {
453
+ for (var ki = 0; ki < opts.knowledgeFiles.length; ki++) {
454
+ var kf = opts.knowledgeFiles[ki];
455
+ try {
456
+ if (!fs.existsSync(kf.filePath)) continue;
457
+ var content = fs.readFileSync(kf.filePath, "utf8").trim();
458
+ if (!content || content.length < 20) continue;
459
+ // Cap at 4000 chars to avoid oversized docs dominating the index
460
+ var capped = content.length > 4000 ? content.substring(0, 4000) : content;
461
+ allDocs.push({
462
+ id: "knowledge:" + (kf.mateName || "self") + ":" + kf.name,
463
+ text: kf.name + " " + kf.name + " " + capped,
464
+ meta: {
465
+ source: "knowledge",
466
+ fileName: kf.name,
467
+ mateName: kf.mateName || null,
468
+ snippet: content.length > 200 ? content.substring(0, 200) + "..." : content
469
+ }
470
+ });
471
+ } catch (e) {}
472
+ }
473
+ }
474
+
450
475
  if (allDocs.length === 0) return [];
451
476
 
452
- // Build unified index across both sources and search
477
+ // Build unified index across all sources and search
453
478
  var index = buildIndex(allDocs);
454
479
  var results = searchIndex(index, opts.query, maxResults);
455
480
 
@@ -463,6 +488,10 @@ function searchMate(opts) {
463
488
  out.digest = r.meta.digest;
464
489
  out.lineIdx = r.meta.lineIdx;
465
490
  out.mateName = r.meta.mateName || null;
491
+ } else if (r.meta.source === "knowledge") {
492
+ out.fileName = r.meta.fileName;
493
+ out.mateName = r.meta.mateName || null;
494
+ out.snippet = r.meta.snippet;
466
495
  } else {
467
496
  out.sessionId = r.meta.sessionId;
468
497
  out.sessionTitle = r.meta.sessionTitle;
@@ -620,6 +649,12 @@ function formatForContext(results) {
620
649
  }
621
650
  line += " (relevance: " + scorePct + ")";
622
651
  lines.push(line);
652
+ } else if (r.source === "knowledge") {
653
+ var line = "- [knowledge: " + (r.fileName || "?") + "] ";
654
+ if (r.mateName) line += "(@" + r.mateName + ") ";
655
+ line += (r.snippet || "(no preview)");
656
+ line += " (relevance: " + scorePct + ")";
657
+ lines.push(line);
623
658
  } else if (r.source === "session") {
624
659
  var line = "- [session: " + (r.sessionTitle || "?") + "] ";
625
660
  line += (r.snippet || "(no preview)");
package/lib/sessions.js CHANGED
@@ -577,7 +577,7 @@ function createSessionManager(opts) {
577
577
  var contentMatch = false;
578
578
  for (var i = 0; i < session.history.length; i++) {
579
579
  var entry = session.history[i];
580
- if ((entry.type === "delta" || entry.type === "user_message" || entry.type === "mention_user" || entry.type === "mention_response" || entry.type === "debate_turn_done") && entry.text) {
580
+ if ((entry.type === "delta" || entry.type === "user_message" || entry.type === "mention_user" || entry.type === "mention_response" || entry.type === "debate_turn_done" || entry.type === "debate_comment_injected") && entry.text) {
581
581
  if (entry.text.toLowerCase().indexOf(q) !== -1) {
582
582
  contentMatch = true;
583
583
  break;
@@ -614,7 +614,7 @@ function createSessionManager(opts) {
614
614
  currentTurnStart = i;
615
615
  lastAssistantHitTurn = -1;
616
616
  }
617
- if ((entry.type === "delta" || entry.type === "user_message" || entry.type === "mention_user" || entry.type === "mention_response" || entry.type === "debate_turn_done") && entry.text) {
617
+ if ((entry.type === "delta" || entry.type === "user_message" || entry.type === "mention_user" || entry.type === "mention_response" || entry.type === "debate_turn_done" || entry.type === "debate_comment_injected") && entry.text) {
618
618
  // Skip duplicate delta hits within the same assistant turn
619
619
  if (entry.type === "delta" && currentTurnStart === lastAssistantHitTurn) continue;
620
620
  var text = entry.text;