clay-server 2.23.1 → 2.23.2-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.
@@ -51,6 +51,12 @@ function buildUserEnv(osUserInfo) {
51
51
  env.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR;
52
52
  }
53
53
 
54
+ // Force Node.js to prefer IPv4. Without this, the SDK CLI subprocess
55
+ // tries IPv6 first (happy eyeballs), times out on servers without IPv6
56
+ // outbound, then falls back to IPv4. This causes multi-second delays
57
+ // on cold start (compounded by exponential backoff retries).
58
+ env.NODE_OPTIONS = (env.NODE_OPTIONS ? env.NODE_OPTIONS + " " : "") + "--dns-result-order=ipv4first";
59
+
54
60
  return env;
55
61
  }
56
62
 
package/lib/project.js CHANGED
@@ -3788,6 +3788,8 @@ function createProjectContext(opts) {
3788
3788
  sendToSession(session.localId, { type: "status", status: "processing" });
3789
3789
  if (!session.queryInstance && (!session.worker || session.messageQueue !== "worker")) {
3790
3790
  // No active query (or worker idle between queries): start a new query
3791
+ session._queryStartTs = Date.now();
3792
+ console.log("[PERF] project.js: startQuery called, localId=" + session.localId + " t=0ms");
3791
3793
  sdk.startQuery(session, fullText, msg.images, getLinuxUserForSession(session));
3792
3794
  } else {
3793
3795
  sdk.pushMessage(session, fullText, msg.images);
@@ -72,6 +72,10 @@ export function initNotifications(_ctx) {
72
72
  if (window.visualViewport) {
73
73
  var layout = $("layout");
74
74
  var mobileTabBar = document.getElementById("mobile-tab-bar");
75
+ // Capture initial viewport height before any keyboard interaction.
76
+ // In iOS PWA standalone mode, window.innerHeight shrinks when the
77
+ // keyboard opens, so we need a stable baseline for comparison.
78
+ var stableViewportHeight = window.innerHeight;
75
79
  function onViewportChange() {
76
80
  var vv = window.visualViewport;
77
81
  // Shrink layout to visual viewport height so input area sits above keyboard
@@ -80,7 +84,7 @@ export function initNotifications(_ctx) {
80
84
  layout.style.top = vv.offsetTop + "px";
81
85
  document.documentElement.scrollTop = 0;
82
86
  // Toggle class so CSS can remove the tab-bar bottom padding while keyboard is up
83
- var keyboardOpen = vv.height < window.innerHeight - 100;
87
+ var keyboardOpen = vv.height < stableViewportHeight - 100;
84
88
  document.body.classList.toggle("keyboard-open", keyboardOpen);
85
89
  if (!keyboardOpen) ctx.scrollToBottom();
86
90
  // Hide tab bar when software keyboard is open
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
@@ -5,7 +5,8 @@
5
5
  // Usage: node sdk-worker.js <socket-path>
6
6
 
7
7
  // 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) {}
8
+ var _workerBootTs = Date.now();
9
+ 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
10
 
10
11
  var net = require("net");
11
12
  var crypto = require("crypto");
@@ -204,9 +205,16 @@ function handleElicitationResponse(msg) {
204
205
 
205
206
  // --- Query handling ---
206
207
  async function handleQueryStart(msg) {
208
+ var t0 = msg._perfT0 || Date.now();
209
+ var localT0 = Date.now();
210
+ function perf(label) { console.log("[PERF] sdk-worker: " + label + " +" + (Date.now() - t0) + "ms (local +" + (Date.now() - localT0) + "ms)"); }
211
+ perf("handleQueryStart entered");
212
+
207
213
  var sdk;
208
214
  try {
215
+ perf("loading SDK");
209
216
  sdk = await getSDK();
217
+ perf("SDK loaded");
210
218
  } catch (e) {
211
219
  sendToDaemon({ type: "query_error", error: "Failed to load SDK: " + (e.message || e), exitCode: null, stderr: null });
212
220
  return;
@@ -223,6 +231,17 @@ async function handleQueryStart(msg) {
223
231
  // Build query options (callbacks are local, everything else from daemon)
224
232
  var options = msg.options || {};
225
233
  options.abortController = abortController;
234
+ // Override CLI subprocess spawn to inject NODE_OPTIONS for IPv4-first DNS.
235
+ // The SDK constructs its own env for the CLI process, so worker env vars
236
+ // like NODE_OPTIONS are not inherited. We intercept the spawn to fix this.
237
+ options.spawnClaudeCodeProcess = function(spawnOpts) {
238
+ spawnOpts.env.NODE_OPTIONS = (spawnOpts.env.NODE_OPTIONS || "") + " --dns-result-order=ipv4first";
239
+ return require("child_process").spawn(spawnOpts.command, spawnOpts.args, {
240
+ cwd: spawnOpts.cwd,
241
+ env: spawnOpts.env,
242
+ stdio: ["pipe", "pipe", "pipe"],
243
+ });
244
+ };
226
245
  options.canUseTool = function(toolName, input, toolOpts) {
227
246
  // AskUserQuestion is handled specially: we send it as a separate IPC type
228
247
  // so the daemon can use its own AskUserQuestion handling logic
@@ -249,11 +268,13 @@ async function handleQueryStart(msg) {
249
268
  return onElicitation(request, elicitOpts);
250
269
  };
251
270
 
271
+ perf("creating query instance");
252
272
  try {
253
273
  queryInstance = sdk.query({
254
274
  prompt: messageQueue,
255
275
  options: options,
256
276
  });
277
+ perf("query instance created");
257
278
  } catch (e) {
258
279
  sendToDaemon({ type: "query_error", error: "Failed to create query: " + (e.message || e), exitCode: null, stderr: null });
259
280
  queryInstance = null;
@@ -269,9 +290,41 @@ async function handleQueryStart(msg) {
269
290
 
270
291
  // Stream events to daemon
271
292
  try {
293
+ var firstEvent = true;
294
+ var firstText = true;
295
+ var eventCounts = {};
272
296
  for await (var event of queryInstance) {
297
+ var etype = (event && event.type || "?");
298
+ var esubtype = (event && event.subtype || "");
299
+ eventCounts[etype] = (eventCounts[etype] || 0) + 1;
300
+ if (firstEvent) {
301
+ perf("FIRST event from SDK (type=" + etype + " subtype=" + esubtype + ")");
302
+ firstEvent = false;
303
+ }
304
+ // Log every non-content event, and the first content/text event
305
+ if (etype !== "content_block_delta" && etype !== "content_block_start" && etype !== "content_block_stop") {
306
+ var extraInfo = "";
307
+ if (esubtype === "api_retry") {
308
+ // Dump full event to see all available fields
309
+ try {
310
+ var retryDump = JSON.stringify(event, function(k, v) {
311
+ if (typeof v === "string" && v.length > 200) return v.substring(0, 200) + "...[truncated]";
312
+ return v;
313
+ });
314
+ extraInfo = " FULL=" + retryDump;
315
+ } catch (je) {
316
+ extraInfo = " keys=" + Object.keys(event).join(",");
317
+ }
318
+ }
319
+ perf("SDK event #" + eventCounts[etype] + " type=" + etype + " subtype=" + esubtype + extraInfo);
320
+ }
321
+ if (firstText && (etype === "content_block_delta" || etype === "assistant" || (etype === "content_block_start"))) {
322
+ perf("FIRST TEXT/CONTENT event (type=" + etype + " subtype=" + esubtype + ")");
323
+ firstText = false;
324
+ }
273
325
  sendToDaemon({ type: "sdk_event", event: event });
274
326
  }
327
+ perf("all events streamed (counts=" + JSON.stringify(eventCounts) + "), sending query_done");
275
328
  sendToDaemon({ type: "query_done" });
276
329
  } catch (err) {
277
330
  var errMsg = err.message || String(err);
@@ -424,9 +477,9 @@ function gracefulExit(code) {
424
477
  var _keepAlive = setInterval(function() {}, 30000);
425
478
 
426
479
  // --- Connect to daemon socket ---
427
- try { require("fs").writeSync(2, "[sdk-worker] Connecting to socket: " + socketPath + "\n"); } catch (e) {}
480
+ try { require("fs").writeSync(2, "[sdk-worker] Connecting to socket: " + socketPath + " +" + (Date.now() - _workerBootTs) + "ms since boot\n"); } catch (e) {}
428
481
  conn = net.connect(socketPath, function() {
429
- try { require("fs").writeSync(2, "[sdk-worker] Connected, sending ready\n"); } catch (e) {}
482
+ try { require("fs").writeSync(2, "[sdk-worker] Connected, sending ready +" + (Date.now() - _workerBootTs) + "ms since boot\n"); } catch (e) {}
430
483
  sendToDaemon({ type: "ready" });
431
484
  });
432
485
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.23.1",
3
+ "version": "2.23.2-beta.1",
4
4
  "description": "Self-hosted Claude Code in your browser. Multi-session, multi-user, push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",