clay-server 2.31.0 → 2.32.0-beta.10

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 (82) hide show
  1. package/lib/browser-mcp-server.js +32 -44
  2. package/lib/codex-defaults.js +18 -0
  3. package/lib/debate-mcp-server.js +14 -31
  4. package/lib/mcp-local.js +31 -1
  5. package/lib/project-connection.js +9 -6
  6. package/lib/project-debate.js +8 -0
  7. package/lib/project-filesystem.js +47 -1
  8. package/lib/project-http.js +75 -8
  9. package/lib/project-mate-interaction.js +102 -16
  10. package/lib/project-mcp.js +4 -0
  11. package/lib/project-notifications.js +9 -0
  12. package/lib/project-sessions.js +94 -51
  13. package/lib/project-user-message.js +12 -7
  14. package/lib/project.js +234 -99
  15. package/lib/public/app.js +135 -454
  16. package/lib/public/codex-avatar.png +0 -0
  17. package/lib/public/css/debate.css +3 -2
  18. package/lib/public/css/filebrowser.css +91 -1
  19. package/lib/public/css/icon-strip.css +21 -5
  20. package/lib/public/css/input.css +338 -104
  21. package/lib/public/css/mates.css +43 -0
  22. package/lib/public/css/mention.css +48 -4
  23. package/lib/public/css/menus.css +1 -1
  24. package/lib/public/css/messages.css +2 -0
  25. package/lib/public/css/notifications-center.css +26 -0
  26. package/lib/public/css/tooltip.css +47 -0
  27. package/lib/public/index.html +78 -26
  28. package/lib/public/modules/app-connection.js +138 -37
  29. package/lib/public/modules/app-cursors.js +18 -17
  30. package/lib/public/modules/app-debate-ui.js +9 -9
  31. package/lib/public/modules/app-dm.js +175 -131
  32. package/lib/public/modules/app-favicon.js +28 -26
  33. package/lib/public/modules/app-header.js +79 -68
  34. package/lib/public/modules/app-home-hub.js +55 -47
  35. package/lib/public/modules/app-loop-ui.js +34 -18
  36. package/lib/public/modules/app-loop-wizard.js +6 -6
  37. package/lib/public/modules/app-messages.js +199 -153
  38. package/lib/public/modules/app-misc.js +23 -12
  39. package/lib/public/modules/app-notifications.js +119 -9
  40. package/lib/public/modules/app-panels.js +203 -49
  41. package/lib/public/modules/app-projects.js +161 -150
  42. package/lib/public/modules/app-rate-limit.js +5 -4
  43. package/lib/public/modules/app-rendering.js +149 -101
  44. package/lib/public/modules/app-skills-install.js +4 -4
  45. package/lib/public/modules/context-sources.js +102 -66
  46. package/lib/public/modules/dom-refs.js +21 -0
  47. package/lib/public/modules/filebrowser.js +173 -2
  48. package/lib/public/modules/input.js +122 -0
  49. package/lib/public/modules/markdown.js +5 -1
  50. package/lib/public/modules/mate-sidebar.js +38 -0
  51. package/lib/public/modules/mention.js +24 -6
  52. package/lib/public/modules/scheduler.js +1 -1
  53. package/lib/public/modules/sidebar-mates.js +79 -35
  54. package/lib/public/modules/sidebar-mobile.js +34 -30
  55. package/lib/public/modules/sidebar-projects.js +60 -57
  56. package/lib/public/modules/sidebar-sessions.js +75 -69
  57. package/lib/public/modules/sidebar.js +12 -20
  58. package/lib/public/modules/skills.js +8 -9
  59. package/lib/public/modules/sticky-notes.js +1 -2
  60. package/lib/public/modules/store.js +9 -2
  61. package/lib/public/modules/stt.js +4 -1
  62. package/lib/public/modules/terminal.js +12 -0
  63. package/lib/public/modules/tools.js +18 -13
  64. package/lib/public/modules/tooltip.js +32 -5
  65. package/lib/sdk-bridge.js +562 -1114
  66. package/lib/sdk-message-processor.js +150 -135
  67. package/lib/sdk-worker.js +4 -0
  68. package/lib/server-dm.js +1 -0
  69. package/lib/server.js +86 -1
  70. package/lib/sessions.js +81 -37
  71. package/lib/ws-schema.js +2 -0
  72. package/lib/yoke/adapters/claude-worker.js +559 -0
  73. package/lib/yoke/adapters/claude.js +1483 -0
  74. package/lib/yoke/adapters/codex.js +1121 -0
  75. package/lib/yoke/adapters/gemini.js +709 -0
  76. package/lib/yoke/codex-app-server.js +307 -0
  77. package/lib/yoke/index.js +199 -0
  78. package/lib/yoke/instructions.js +62 -0
  79. package/lib/yoke/interface.js +98 -0
  80. package/lib/yoke/mcp-bridge-server.js +294 -0
  81. package/lib/yoke/package.json +7 -0
  82. package/package.json +3 -1
package/lib/sdk-bridge.js CHANGED
@@ -1,11 +1,9 @@
1
1
  const crypto = require("crypto");
2
2
  var fs = require("fs");
3
3
  var path = require("path");
4
- var os = require("os");
5
- var net = require("net");
6
- var { execSync, spawn } = require("child_process");
7
- var { resolveOsUserInfo } = require("./os-users");
4
+ var { execSync } = require("child_process");
8
5
  var usersModule = require("./users");
6
+ var { getCodexConfig } = require("./codex-defaults");
9
7
  var { splitShellSegments, attachSkillDiscovery } = require("./sdk-skill-discovery");
10
8
  var { createMessageQueue } = require("./sdk-message-queue");
11
9
  var { attachMessageProcessor } = require("./sdk-message-processor");
@@ -74,17 +72,26 @@ function mergeMcpServers(localServers, getRemoteFn) {
74
72
  merged[lk[i]] = localServers[lk[i]];
75
73
  hasAny = true;
76
74
  }
75
+ console.log("[mergeMcpServers] local servers:", lk.join(", ") || "(none)");
76
+ } else {
77
+ console.log("[mergeMcpServers] local servers: null");
77
78
  }
78
79
  if (typeof getRemoteFn === "function") {
79
80
  var remote = getRemoteFn();
80
81
  if (remote) {
81
82
  var rk = Object.keys(remote);
83
+ console.log("[mergeMcpServers] remote servers:", rk.join(", ") || "(none)");
82
84
  for (var j = 0; j < rk.length; j++) {
83
85
  merged[rk[j]] = remote[rk[j]];
84
86
  hasAny = true;
85
87
  }
88
+ } else {
89
+ console.log("[mergeMcpServers] remote servers: null/empty");
86
90
  }
91
+ } else {
92
+ console.log("[mergeMcpServers] getRemoteFn not a function");
87
93
  }
94
+ console.log("[mergeMcpServers] merged result:", Object.keys(merged).join(", ") || "(none)");
88
95
  return hasAny ? merged : null;
89
96
  }
90
97
 
@@ -95,12 +102,16 @@ function createSDKBridge(opts) {
95
102
  var send = opts.send; // broadcast to all clients
96
103
  var pushModule = opts.pushModule;
97
104
  var getNotificationsModule = opts.getNotificationsModule || function () { return null; };
98
- var getSDK = opts.getSDK;
105
+ var adapter = opts.adapter;
106
+ var adapters = opts.adapters || {};
99
107
  var mateDisplayName = opts.mateDisplayName || "";
100
108
  var isMate = opts.isMate || (slug.indexOf("mate-") === 0);
101
109
  var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
102
110
  var mcpServers = opts.mcpServers || null;
103
111
  var getRemoteMcpServers = opts.getRemoteMcpServers || null;
112
+ var clayPort = opts.clayPort || 2633;
113
+ var clayTls = opts.clayTls || false;
114
+ var clayAuthToken = opts.clayAuthToken || null;
104
115
  var onProcessingChanged = opts.onProcessingChanged || function () {};
105
116
  var onTurnDone = opts.onTurnDone || null;
106
117
 
@@ -121,11 +132,10 @@ function createSDKBridge(opts) {
121
132
  _idleReaperTimer = setInterval(function () {
122
133
  var now = Date.now();
123
134
  sm.sessions.forEach(function (session) {
124
- // Skip sessions that are actively processing, have no query, use workers,
135
+ // Skip sessions that are actively processing, have no query,
125
136
  // or are single-turn (Ralph Loop — managed by onQueryComplete).
126
137
  if (session.isProcessing) return;
127
138
  if (!session.queryInstance) return;
128
- if (session.worker) return;
129
139
  if (session.singleTurn) return;
130
140
  if (session.destroying) return;
131
141
 
@@ -134,9 +144,12 @@ function createSDKBridge(opts) {
134
144
  console.log("[sdk-bridge] Reaping idle session " + session.localId +
135
145
  " (idle " + Math.round((now - lastActivity) / 60000) + "min)" +
136
146
  (session.title ? " title=" + JSON.stringify(session.title) : ""));
137
- // End the message queue so the for-await loop in processQueryStream
147
+ // End the query so the for-await loop in processQueryStream
138
148
  // exits naturally, triggering the finally block cleanup.
139
- if (session.messageQueue && typeof session.messageQueue.end === "function") {
149
+ // Works for both in-process (messageQueue.end) and worker (handle.close) paths.
150
+ if (session.queryInstance && typeof session.queryInstance.close === "function") {
151
+ try { session.queryInstance.close(); } catch (e) {}
152
+ } else if (session.messageQueue && typeof session.messageQueue.end === "function") {
140
153
  try { session.messageQueue.end(); } catch (e) {}
141
154
  }
142
155
  }
@@ -159,17 +172,58 @@ function createSDKBridge(opts) {
159
172
  var mergeSkills = skills.mergeSkills;
160
173
 
161
174
  // --- Message processing (extracted to sdk-message-processor.js) ---
175
+ // Auto-generate a session title via YOKE adapter.generateTitle().
176
+ // Triggered by sdk-message-processor after AUTO_TITLE_TURN_THRESHOLD turns.
177
+ function autoGenerateTitle(session) {
178
+ if (typeof adapter.generateTitle !== "function") {
179
+ console.log("[auto-title] adapter.generateTitle not available for vendor=" + adapter.vendor);
180
+ return;
181
+ }
182
+ var userMessages = [];
183
+ for (var i = 0; i < session.history.length; i++) {
184
+ var entry = session.history[i];
185
+ if (entry.type === "user_message" && entry.text) {
186
+ userMessages.push(entry.text.substring(0, 200));
187
+ if (userMessages.length >= 5) break;
188
+ }
189
+ }
190
+ if (userMessages.length === 0) {
191
+ console.log("[auto-title] No user messages found in session " + session.localId);
192
+ return;
193
+ }
194
+ console.log("[auto-title] Calling adapter.generateTitle with " + userMessages.length + " messages for session " + session.localId);
195
+
196
+ adapter.generateTitle(userMessages, { cwd: cwd }).then(function(title) {
197
+ if (!title || title.length < 2) return;
198
+ title = title.substring(0, 100);
199
+ if (!session.titleManuallySet) {
200
+ session.title = title;
201
+ session.titleAutoGenerated = true;
202
+ sm.saveSessionFile(session);
203
+ sm.broadcastSessionList();
204
+ if (session.cliSessionId && typeof adapter.renameSession === "function") {
205
+ adapter.renameSession(session.cliSessionId, title, { dir: cwd }).catch(function () {});
206
+ }
207
+ console.log("[auto-title] Generated title for session " + session.localId + ": " + title);
208
+ }
209
+ }).catch(function(e) {
210
+ console.error("[auto-title] Failed:", e.message || e);
211
+ });
212
+ }
213
+
162
214
  var msgProcessor = attachMessageProcessor({
163
215
  sm: sm,
164
216
  send: send,
165
217
  slug: slug,
218
+ cwd: cwd,
166
219
  isMate: isMate,
167
220
  mateDisplayName: mateDisplayName,
168
221
  pushModule: pushModule,
169
222
  getNotificationsModule: getNotificationsModule,
170
- getSDK: getSDK,
223
+ adapter: adapter,
171
224
  onProcessingChanged: onProcessingChanged,
172
225
  onTurnDone: onTurnDone,
226
+ onAutoTitle: function (session) { autoGenerateTitle(session); },
173
227
  opts: opts,
174
228
  discoverSkillDirs: discoverSkillDirs,
175
229
  mergeSkills: mergeSkills,
@@ -223,817 +277,48 @@ function createSDKBridge(opts) {
223
277
  });
224
278
  }
225
279
 
226
- // --- Worker process management (OS-level multi-user) ---
227
-
228
- var WORKER_SCRIPT = path.join(__dirname, "sdk-worker.js");
229
280
 
230
- // Ensure the package directory tree is world-readable so OS-level users
231
- // can access the worker script and its dependencies (the install path
232
- // may be under /root/.npm/_npx/ which defaults to 700)
233
- (function ensurePackageReadable() {
281
+ // --- Linux user project directory setup ---
282
+ // Ensures the linux user's .claude project directory exists and is writable,
283
+ // then pre-copies CLI session file if needed. Called before starting a query
284
+ // so the worker can resume from the correct session file.
285
+ function ensureLinuxUserProjectDir(linuxUser, session) {
234
286
  try {
235
- // Walk up from __dirname to find the package root (where node_modules lives)
236
- var pkgDir = path.join(__dirname, "..");
237
- // Open read+execute on each ancestor directory up to and including the
238
- // npx cache entry so that non-root users can traverse the path
239
- var dir = pkgDir;
240
- var dirs = [];
241
- while (dir !== path.dirname(dir)) {
242
- dirs.push(dir);
243
- dir = path.dirname(dir);
244
- }
245
- // Open o+rx on each ancestor so non-root users can traverse the path
246
- // (e.g. /root/.npm/_npx/.../node_modules/clay-server needs /root to be o+x)
247
- for (var di = 0; di < dirs.length; di++) {
248
- try {
249
- var st = fs.statSync(dirs[di]);
250
- // Add o+x (traverse) to all ancestors, o+rx to npm cache dirs
251
- var isNpmDir = dirs[di].indexOf(".npm") !== -1 || dirs[di].indexOf("node_modules") !== -1;
252
- var needed = isNpmDir ? 0o005 : 0o001; // rx for npm dirs, just x for ancestors like /root
253
- if ((st.mode & needed) !== needed) {
254
- fs.chmodSync(dirs[di], st.mode | needed);
255
- }
256
- } catch (e) {}
257
- }
258
- // Recursively make the package AND hoisted dependencies readable.
259
- // npm/npx may hoist deps (e.g. @anthropic-ai/claude-agent-sdk) to the
260
- // parent node_modules/ instead of inside clay-server/node_modules/.
261
- var { execSync: chmodExec } = require("child_process");
262
- // Find the top-level node_modules that contains clay-server
263
- var topNodeModules = path.join(pkgDir, "..");
264
- if (path.basename(topNodeModules) === "node_modules") {
265
- chmodExec("chmod -R o+rX " + JSON.stringify(topNodeModules), { stdio: "ignore", timeout: 15000 });
266
- } else {
267
- chmodExec("chmod -R o+rX " + JSON.stringify(pkgDir), { stdio: "ignore", timeout: 5000 });
268
- }
269
- } catch (e) {}
270
- })();
271
-
272
- // resolveLinuxUser delegates to shared os-users utility
273
- function resolveLinuxUser(username) {
274
- return resolveOsUserInfo(username);
275
- }
276
-
277
- /**
278
- * Spawn an SDK worker process running as the given Linux user.
279
- * Returns a worker handle with send/kill/event methods.
280
- */
281
- function spawnWorker(linuxUser) {
282
- var userInfo = resolveLinuxUser(linuxUser);
283
- var socketId = crypto.randomUUID();
284
- var socketPath = path.join(os.tmpdir(), "clay-worker-" + socketId + ".sock");
285
-
286
- var worker = {
287
- process: null,
288
- connection: null,
289
- socketPath: socketPath,
290
- server: null,
291
- messageHandlers: [],
292
- ready: false,
293
- readyPromise: null,
294
- _readyResolve: null,
295
- buffer: "",
296
- };
297
-
298
- worker.readyPromise = new Promise(function(resolve) {
299
- worker._readyResolve = resolve;
300
- });
301
-
302
- // Resolves when the worker process actually exits.
303
- // Used to prevent spawning a new worker before the old one finishes
304
- // flushing SDK session state to disk (race condition on resume).
305
- worker.exitPromise = new Promise(function(resolve) {
306
- worker._exitResolve = resolve;
307
- });
308
-
309
- // Create Unix socket server
310
- var spawnT0 = Date.now();
311
- worker.server = net.createServer(function(connection) {
312
- console.log("[PERF] spawnWorker: socket connection accepted +" + (Date.now() - spawnT0) + "ms");
313
- worker.connection = connection;
314
- connection.on("data", function(chunk) {
315
- worker.buffer += chunk.toString();
316
- var lines = worker.buffer.split("\n");
317
- worker.buffer = lines.pop();
318
- for (var i = 0; i < lines.length; i++) {
319
- if (!lines[i].trim()) continue;
287
+ var configMod = require("./config");
288
+ var osUsersMod = require("./os-users");
289
+ var originalHome = configMod.REAL_HOME || require("os").homedir();
290
+ var linuxUserHome = osUsersMod.getLinuxUserHome(linuxUser);
291
+ var uid = osUsersMod.getLinuxUserUid(linuxUser);
292
+ if (originalHome !== linuxUserHome && uid != null) {
293
+ var projectSlug = (cwd || "").replace(/\//g, "-");
294
+ var dstDir = path.join(linuxUserHome, ".claude", "projects", projectSlug);
295
+ // Create and chown the project directory once
296
+ if (!fs.existsSync(dstDir)) {
297
+ fs.mkdirSync(dstDir, { recursive: true });
298
+ try { require("child_process").execSync("chown -R " + uid + " " + JSON.stringify(path.join(linuxUserHome, ".claude"))); } catch (e2) {}
299
+ } else {
320
300
  try {
321
- var msg = JSON.parse(lines[i]);
322
- if (msg.type === "ready") {
323
- console.log("[PERF] spawnWorker: 'ready' IPC received +" + (Date.now() - spawnT0) + "ms");
324
- worker.ready = true;
325
- if (worker._readyResolve) {
326
- worker._readyResolve();
327
- worker._readyResolve = null;
328
- }
329
- }
330
- for (var h = 0; h < worker.messageHandlers.length; h++) {
331
- worker.messageHandlers[h](msg);
332
- }
333
- } catch (e) {
334
- console.error("[sdk-bridge] Failed to parse worker message:", e.message);
335
- }
336
- }
337
- });
338
- connection.on("error", function(err) {
339
- console.error("[sdk-bridge] Worker connection error:", err.message);
340
- });
341
- });
342
-
343
- worker.server.listen(socketPath, function() {
344
- console.log("[PERF] spawnWorker: socket listen ready +" + (Date.now() - spawnT0) + "ms");
345
- // Set socket permissions so the target user can connect
346
- try { fs.chmodSync(socketPath, 0o777); } catch (e) {}
347
-
348
- // Spawn worker process as the target Linux user.
349
- // Build a minimal, isolated env (no daemon env leakage).
350
- var workerEnv = require("./build-user-env").buildUserEnv({
351
- uid: userInfo.uid,
352
- gid: userInfo.gid,
353
- home: userInfo.home,
354
- user: linuxUser,
355
- shell: userInfo.shell || "/bin/bash",
356
- });
357
-
358
- console.log("[sdk-bridge] Spawning worker: uid=" + userInfo.uid + " gid=" + userInfo.gid + " cwd=" + cwd + " socket=" + socketPath);
359
- console.log("[sdk-bridge] Worker script: " + WORKER_SCRIPT);
360
- console.log("[sdk-bridge] Node: " + process.execPath);
361
- worker.process = spawn(process.execPath, [WORKER_SCRIPT, socketPath], {
362
- uid: userInfo.uid,
363
- gid: userInfo.gid,
364
- env: workerEnv,
365
- cwd: cwd,
366
- stdio: ["ignore", "pipe", "pipe"],
367
- });
368
-
369
- worker.process.stdout.on("data", function(data) {
370
- console.log("[sdk-worker:" + linuxUser + "] " + data.toString().trim());
371
- });
372
- worker._stderrBuf = "";
373
- worker.process.stderr.on("data", function(data) {
374
- var text = data.toString().trim();
375
- worker._stderrBuf += text + "\n";
376
- console.error("[sdk-worker:" + linuxUser + "] " + text);
377
- });
378
-
379
- worker.process.on("exit", function(code, signal) {
380
- console.log("[sdk-bridge] Worker for " + linuxUser + " exited (code=" + code + ", signal=" + signal + ")" + (worker._stderrBuf ? " stderr: " + worker._stderrBuf.trim() : ""));
381
- // Reject readyPromise if worker dies before becoming ready
382
- if (!worker.ready && worker._readyResolve) {
383
- worker._readyResolve = null;
384
- // Let the readyPromise hang; the query_error handler will clean up
385
- }
386
- // Notify message handlers about unexpected exit so sessions don't hang.
387
- // Always dispatch a fallback query_error. The handler is idempotent:
388
- // it checks isProcessing before taking action, and cleanupSessionWorker
389
- // guards against stale workers. This covers all exit cases including
390
- // signal kills (code=null) and normal exits where the IPC query_error
391
- // was lost due to connection timing.
392
- console.log("[sdk-bridge] Exit handler: pid=" + (worker.process ? worker.process.pid : "?") + " ready=" + worker.ready + " _queryEnded=" + worker._queryEnded + " _abortSent=" + worker._abortSent + " handlers=" + worker.messageHandlers.length);
393
- if (code === 0 && !worker.ready) {
394
- // Worker exited cleanly before sending "ready"
395
- for (var h = 0; h < worker.messageHandlers.length; h++) {
396
- worker.messageHandlers[h]({
397
- type: "query_error",
398
- error: "Worker exited before ready (code=0). stderr: " + (worker._stderrBuf || "(none)"),
399
- exitCode: 0,
400
- stderr: worker._stderrBuf || null,
401
- });
402
- }
403
- } else if (code !== 0 || code === null || signal) {
404
- // Worker crashed, was killed by signal, or exited abnormally
405
- var stderrText = worker._stderrBuf || "";
406
- var exitReason = signal
407
- ? "Worker killed by " + signal
408
- : (stderrText || "Worker exited with code " + code);
409
- for (var h = 0; h < worker.messageHandlers.length; h++) {
410
- worker.messageHandlers[h]({
411
- type: "query_error",
412
- error: exitReason,
413
- exitCode: code,
414
- stderr: stderrText || null,
415
- });
416
- }
417
- } else if (worker.messageHandlers.length > 0) {
418
- // Normal exit (code=0, ready=true). Dispatch fallback in case the
419
- // IPC query_done/query_error was lost (e.g. connection closed early).
420
- var fallbackMsg = worker._abortSent
421
- ? "Worker aborted"
422
- : "Worker exited before query completed";
423
- for (var h = 0; h < worker.messageHandlers.length; h++) {
424
- worker.messageHandlers[h]({
425
- type: "query_error",
426
- error: fallbackMsg,
427
- exitCode: 0,
428
- stderr: worker._stderrBuf || null,
429
- _fallback: true,
430
- });
431
- }
432
- }
433
- cleanupWorker(worker);
434
- if (worker._exitResolve) {
435
- worker._exitResolve();
436
- worker._exitResolve = null;
437
- }
438
- });
439
- });
440
-
441
- worker.send = function(msg) {
442
- if (!worker.connection || worker.connection.destroyed) return;
443
- try {
444
- worker.connection.write(JSON.stringify(msg) + "\n");
445
- } catch (e) {
446
- console.error("[sdk-bridge] Failed to send to worker:", e.message);
447
- }
448
- };
449
-
450
- worker.onMessage = function(handler) {
451
- worker.messageHandlers.push(handler);
452
- };
453
-
454
- worker.kill = function() {
455
- console.log("[sdk-bridge] worker.kill() called, pid=" + (worker.process ? worker.process.pid : "?") + " stack=" + new Error().stack.split("\n").slice(1, 4).join(" | "));
456
- worker.send({ type: "shutdown" });
457
- // Force kill after 5 seconds if still alive (gives SDK time to save session)
458
- setTimeout(function() {
459
- if (worker.process && !worker.process.killed) {
460
- try { worker.process.kill("SIGKILL"); } catch (e) {}
461
- }
462
- }, 5000);
463
- // Don't call cleanupWorker here. Let the exit handler do it after
464
- // the worker has had time to save SDK session state to disk.
465
- // Closing the connection prematurely causes the worker to exit
466
- // before the SDK can flush its session file, leading to "no
467
- // conversation found" errors on resume (OS multi-user mode).
468
- };
469
-
470
- return worker;
471
- }
472
-
473
- function cleanupWorker(worker) {
474
- console.log("[sdk-bridge] cleanupWorker() called, pid=" + (worker.process ? worker.process.pid : "?") + " stack=" + new Error().stack.split("\n").slice(1, 4).join(" | "));
475
- if (worker._abortTimeout) { clearTimeout(worker._abortTimeout); worker._abortTimeout = null; }
476
- if (worker.connection && !worker.connection.destroyed) {
477
- try { worker.connection.end(); } catch (e) {}
478
- }
479
- if (worker.server) {
480
- try { worker.server.close(); } catch (e) {}
481
- }
482
- // Remove socket file
483
- try { fs.unlinkSync(worker.socketPath); } catch (e) {}
484
- worker.ready = false;
485
- }
486
-
487
- /**
488
- * Start a query via a worker process running as the target Linux user.
489
- * Mirrors the in-process startQuery flow but delegates SDK execution to the worker.
490
- */
491
- async function startQueryViaWorker(session, text, images, linuxUser) {
492
- var t0 = session._queryStartTs || Date.now();
493
- function perf(label) { console.log("[PERF] sdk-bridge: " + label + " +" + (Date.now() - t0) + "ms"); }
494
- perf("startQueryViaWorker entered");
495
-
496
- // Wait for the previous worker to fully exit before spawning a new one.
497
- // Without this, the new worker may try to resume the SDK session file
498
- // while the old worker is still flushing it to disk (800ms grace period),
499
- // causing "no conversation found" and losing all prior context.
500
- if (session._workerExitPromise) {
501
- perf("waiting for old worker exit");
502
- var exitWait = session._workerExitPromise;
503
- session._workerExitPromise = null;
504
- await Promise.race([
505
- exitWait,
506
- new Promise(function(resolve) { setTimeout(resolve, 3000); }),
507
- ]);
508
- perf("old worker exit wait done");
509
- }
510
-
511
- // Reuse existing worker if alive, otherwise spawn a new one.
512
- // Spawn FIRST so the worker starts booting while we do dir setup below.
513
- var worker;
514
- var reusingWorker = false;
515
- if (session.worker && session.worker.ready && session.worker.process && !session.worker.process.killed) {
516
- worker = session.worker;
517
- reusingWorker = true;
518
- // Clear old message handlers so they don't fire for the new query
519
- worker.messageHandlers = [];
520
- worker._queryEnded = false;
521
- worker._abortSent = false;
522
- perf("reusing existing worker pid=" + (worker.process ? worker.process.pid : "?"));
523
- } else {
524
- try {
525
- perf("spawning new worker");
526
- worker = spawnWorker(linuxUser);
527
- perf("spawnWorker returned");
528
- session.worker = worker;
529
- } catch (e) {
530
- session.isProcessing = false;
531
- onProcessingChanged();
532
- sendAndRecord(session, { type: "error", text: "Failed to spawn worker for " + linuxUser + ": " + (e.message || e) });
533
- sendAndRecord(session, { type: "done", code: 1 });
534
- sm.broadcastSessionList();
535
- return;
536
- }
537
- }
538
-
539
- // Ensure the linux user's .claude project directory exists and is writable,
540
- // then pre-copy CLI session file if needed. This runs while the worker is
541
- // booting (readyPromise pending), so it adds no extra latency.
542
- perf("dir setup start");
543
- if (linuxUser) {
544
- try {
545
- var configMod = require("./config");
546
- var osUsersMod = require("./os-users");
547
- var originalHome = configMod.REAL_HOME || require("os").homedir();
548
- var linuxUserHome = osUsersMod.getLinuxUserHome(linuxUser);
549
- var uid = osUsersMod.getLinuxUserUid(linuxUser);
550
- if (originalHome !== linuxUserHome && uid != null) {
551
- var projectSlug = (cwd || "").replace(/\//g, "-");
552
- var dstDir = path.join(linuxUserHome, ".claude", "projects", projectSlug);
553
- // Create and chown the project directory once
554
- if (!fs.existsSync(dstDir)) {
555
- fs.mkdirSync(dstDir, { recursive: true });
556
- try { require("child_process").execSync("chown -R " + uid + " " + JSON.stringify(path.join(linuxUserHome, ".claude"))); } catch (e2) {}
557
- } else {
558
- try {
559
- var dirStat = fs.statSync(dstDir);
560
- if (dirStat.uid !== uid) {
561
- require("child_process").execSync("chown " + uid + " " + JSON.stringify(dstDir));
562
- }
563
- } catch (e2) {}
564
- }
565
- // Pre-copy CLI session file so the worker can resume the conversation
566
- if (session.cliSessionId) {
567
- var sessionFileName = session.cliSessionId + ".jsonl";
568
- var srcFile = path.join(originalHome, ".claude", "projects", projectSlug, sessionFileName);
569
- var dstFile = path.join(dstDir, sessionFileName);
570
- if (fs.existsSync(srcFile) && !fs.existsSync(dstFile)) {
571
- fs.copyFileSync(srcFile, dstFile);
572
- try { require("child_process").execSync("chown " + uid + " " + JSON.stringify(dstFile)); } catch (e2) {}
573
- console.log("[sdk-bridge] Pre-copied CLI session " + session.cliSessionId + " to " + linuxUser);
301
+ var dirStat = fs.statSync(dstDir);
302
+ if (dirStat.uid !== uid) {
303
+ require("child_process").execSync("chown " + uid + " " + JSON.stringify(dstDir));
574
304
  }
575
- }
576
- }
577
- } catch (copyErr) {
578
- console.log("[sdk-bridge] Dir setup / session pre-copy skipped:", copyErr.message);
579
- }
580
- }
581
- perf("dir setup done");
582
-
583
- session.messageQueue = "worker"; // sentinel: messages go via worker IPC
584
- session.blocks = {};
585
- session.sentToolResults = {};
586
- session.activeTaskToolIds = {};
587
- session.pendingElicitations = {};
588
- session.streamedText = false;
589
- session.responsePreview = "";
590
- session.abortController = { abort: function() {
591
- console.log("[sdk-bridge] ABORT sent to worker pid=" + (worker.process ? worker.process.pid : "?"));
592
- worker._abortSent = true;
593
- try { worker.send({ type: "abort" }); } catch (e) {}
594
- // If the worker doesn't finish within 5s (e.g. subagent stuck), force-kill it.
595
- // The worker exit handler will dispatch a fallback query_error and send done.
596
- if (worker._abortTimeout) clearTimeout(worker._abortTimeout);
597
- worker._abortTimeout = setTimeout(function() {
598
- if (worker.process && !worker.process.killed && session.isProcessing) {
599
- console.log("[sdk-bridge] Abort timeout: force-killing worker pid=" + (worker.process ? worker.process.pid : "?"));
600
- try { worker.process.kill("SIGKILL"); } catch (e) {}
305
+ } catch (e2) {}
601
306
  }
602
- }, 5000);
603
- } };
604
-
605
- // Build initial user message content
606
- var content = [];
607
- if (images && images.length > 0) {
608
- for (var i = 0; i < images.length; i++) {
609
- content.push({
610
- type: "image",
611
- source: { type: "base64", media_type: images[i].mediaType, data: images[i].data },
612
- });
613
- }
614
- }
615
- if (text) {
616
- content.push({ type: "text", text: text });
617
- }
618
-
619
- var initialMessage = {
620
- type: "user",
621
- message: { role: "user", content: content },
622
- };
623
-
624
- // Build serializable query options (no callbacks, no AbortController)
625
- var queryOptions = {
626
- cwd: cwd,
627
- settingSources: ["user", "project", "local"],
628
- includePartialMessages: true,
629
- enableFileCheckpointing: true,
630
- extraArgs: { "replay-user-messages": null },
631
- promptSuggestions: true,
632
- agentProgressSummaries: true,
633
- };
634
-
635
- // MCP servers contain circular references (McpServer instances) and cannot
636
- // be serialized for IPC. Instead, extract serializable tool descriptors and
637
- // proxy tool calls back to the daemon via IPC.
638
- var _mergedMcp = mergeMcpServers(mcpServers, getRemoteMcpServers);
639
- var _mcpDescriptors = extractMcpDescriptors(_mergedMcp);
640
- // Do NOT put _mergedMcp into queryOptions; the worker will reconstruct
641
- // MCP servers from descriptors with IPC-proxied handlers.
642
-
643
- // Per-loop settings override global defaults when present
644
- var ls2 = session.loopSettings || {};
645
-
646
- if (ls2.model || sm.currentModel) queryOptions.model = ls2.model || sm.currentModel;
647
- if (ls2.effort || sm.currentEffort) queryOptions.effort = ls2.effort || sm.currentEffort;
648
- if (sm.currentBetas && sm.currentBetas.length > 0) queryOptions.betas = sm.currentBetas;
649
-
650
- var thinkingMode2 = ls2.thinking || sm.currentThinking;
651
- if (thinkingMode2 === "disabled") {
652
- queryOptions.thinking = { type: "disabled" };
653
- } else if (thinkingMode2 === "budget") {
654
- var budgetTokens2 = ls2.thinkingBudget || sm.currentThinkingBudget;
655
- if (budgetTokens2) queryOptions.thinking = { type: "enabled", budgetTokens: budgetTokens2 };
656
- }
657
-
658
- if (ls2.disableAllHooks !== undefined) {
659
- queryOptions.settings = Object.assign({}, queryOptions.settings || {}, { disableAllHooks: ls2.disableAllHooks });
660
- }
661
-
662
- if (dangerouslySkipPermissions) {
663
- queryOptions.allowDangerouslySkipPermissions = true;
664
- }
665
- var modeToApply = ls2.permissionMode || (session.acceptEditsAfterStart ? "acceptEdits" : sm.currentPermissionMode);
666
- if (session.acceptEditsAfterStart) delete session.acceptEditsAfterStart;
667
- if (modeToApply && modeToApply !== "default") {
668
- queryOptions.permissionMode = modeToApply;
669
- }
670
-
671
- if (session.cliSessionId) {
672
- queryOptions.resume = session.cliSessionId;
673
- if (session.lastRewindUuid) {
674
- queryOptions.resumeSessionAt = session.lastRewindUuid;
675
- delete session.lastRewindUuid;
676
- // Persist the deletion so server restarts don't re-use a stale UUID
677
- sm.saveSessionFile(session);
678
- }
679
- }
680
-
681
- // Set up message handler for worker events
682
- var firstEventLogged = false;
683
- worker.onMessage(function(msg) {
684
- if (!firstEventLogged && msg.type === "sdk_event") {
685
- firstEventLogged = true;
686
- perf("FIRST sdk_event received (type=" + (msg.event && msg.event.type || "?") + ")");
687
- }
688
- switch (msg.type) {
689
- case "sdk_event":
690
- processSDKMessage(session, msg.event);
691
- break;
692
-
693
- case "permission_request":
694
- handleCanUseTool(session, msg.toolName, msg.input, {
695
- toolUseID: msg.toolUseId,
696
- decisionReason: msg.decisionReason,
697
- signal: session.abortController ? { addEventListener: function() {} } : undefined,
698
- }).then(function(result) {
699
- worker.send({ type: "permission_response", requestId: msg.requestId, result: result });
700
- }).catch(function(e) {
701
- console.error("[sdk-bridge] permission_response send failed:", e.message || e);
702
- });
703
- break;
704
-
705
- case "ask_user_request":
706
- // Delegate to the daemon's AskUserQuestion handling
707
- handleCanUseTool(session, "AskUserQuestion", msg.input, {
708
- toolUseID: msg.toolUseId,
709
- signal: session.abortController ? { addEventListener: function() {} } : undefined,
710
- }).then(function(result) {
711
- worker.send({ type: "ask_user_response", toolUseId: msg.toolUseId, result: result });
712
- }).catch(function(e) {
713
- console.error("[sdk-bridge] ask_user_response send failed:", e.message || e);
714
- });
715
- break;
716
-
717
- case "elicitation_request":
718
- handleElicitation(session, {
719
- serverName: msg.serverName,
720
- message: msg.message,
721
- mode: msg.mode,
722
- url: msg.url,
723
- elicitationId: msg.elicitationId,
724
- requestedSchema: msg.requestedSchema,
725
- }, {
726
- signal: session.abortController ? { addEventListener: function() {} } : undefined,
727
- }).then(function(result) {
728
- worker.send({ type: "elicitation_response", requestId: msg.requestId, result: result });
729
- }).catch(function(e) {
730
- console.error("[sdk-bridge] elicitation_response send failed:", e.message || e);
731
- });
732
- break;
733
-
734
- case "mcp_tool_call":
735
- // Worker is proxying an MCP tool call back to the daemon where
736
- // the actual MCP server instances (with handlers) live.
737
- callMcpToolHandler(_mergedMcp, msg.serverName, msg.toolName, msg.args)
738
- .then(function(result) {
739
- worker.send({ type: "mcp_tool_result", requestId: msg.requestId, result: result });
740
- })
741
- .catch(function(e) {
742
- worker.send({ type: "mcp_tool_result", requestId: msg.requestId, error: e.message || String(e) });
743
- });
744
- break;
745
-
746
- case "context_usage":
747
- session.lastContextUsage = msg.data;
748
- sendToSession(session, { type: "context_usage", data: msg.data });
749
- break;
750
-
751
- case "query_done":
752
- console.log("[sdk-bridge] IPC query_done received, pid=" + (worker.process ? worker.process.pid : "?"));
753
- // Mark that we received a proper IPC completion, so the exit
754
- // handler fallback knows not to double-process.
755
- worker._queryEnded = true;
756
- // Stream ended normally
757
- if (session.isProcessing && session.taskStopRequested) {
758
- session.isProcessing = false;
759
- onProcessingChanged();
760
- sendAndRecord(session, { type: "info", text: "Interrupted \u00b7 What should Claude do instead?" });
761
- sendAndRecord(session, { type: "done", code: 0 });
762
- sm.broadcastSessionList();
763
- }
764
- cleanupSessionWorker(session, worker);
765
- // Mark session as done so late rate_limit_event can detect race condition
766
- session.isProcessing = false;
767
- // Auto-continue on rate limit (scheduler sessions, or user setting)
768
- var doneDidScheduleAC = false;
769
- var doneACEnabled = session.onQueryComplete || (typeof opts.getAutoContinueSetting === "function" && opts.getAutoContinueSetting(session));
770
- console.log("[sdk-bridge] query_done: session " + session.localId + " rateLimitResetsAt=" + session.rateLimitResetsAt + " acEnabled=" + doneACEnabled + " destroying=" + session.destroying + " scheduledMessage=" + !!session.scheduledMessage);
771
- if (session.rateLimitResetsAt && session.rateLimitResetsAt > Date.now()
772
- && doneACEnabled && !session.destroying) {
773
- var doneResetsAt = session.rateLimitResetsAt;
774
- session.rateLimitResetsAt = null;
775
- session.rateLimitAutoContinuePending = true;
776
- doneDidScheduleAC = true;
777
- console.log("[sdk-bridge] Rate limited (worker/query_done), scheduling auto-continue for session " + session.localId);
778
- if (typeof opts.scheduleMessage === "function") {
779
- opts.scheduleMessage(session, "continue", doneResetsAt);
780
- }
781
- }
782
- if (session.onQueryComplete && !doneDidScheduleAC) {
783
- try { session.onQueryComplete(session); } catch (err) {
784
- console.error("[sdk-bridge] onQueryComplete error:", err.message || err);
785
- }
786
- }
787
- break;
788
-
789
- case "query_error": {
790
- console.log("[sdk-bridge] IPC query_error received, pid=" + (worker.process ? worker.process.pid : "?") + " _fallback=" + !!msg._fallback + " _queryEnded=" + worker._queryEnded + " error=" + (msg.error || "").substring(0, 100));
791
- // Skip fallback errors from exit handler if we already handled the real one
792
- if (msg._fallback && worker._queryEnded) break;
793
- // Mark that we received a proper IPC completion
794
- worker._queryEnded = true;
795
- // Check session-not-found before isProcessing gate (it can arrive after processing is cleared)
796
- var qerrLower = (msg.error || "").toLowerCase();
797
- // Only match the exact SDK error, not generic worker stderr
798
- var isSessionNotFound = qerrLower.indexOf("no conversation found with session id") !== -1;
799
- if (isSessionNotFound) {
800
- // Clear stale cliSessionId so next message starts a fresh
801
- // conversation in the same UI session.
802
- session.cliSessionId = null;
803
- }
804
- if (session.isProcessing) {
805
- session.isProcessing = false;
806
- onProcessingChanged();
807
- var isAbort = (msg.error && (msg.error.indexOf("AbortError") !== -1 || msg.error.indexOf("aborted") !== -1))
808
- || session.taskStopRequested;
809
- if (isAbort) {
810
- if (!session.destroying) {
811
- sendAndRecord(session, { type: "info", text: "Interrupted \u00b7 What should Claude do instead?" });
812
- sendAndRecord(session, { type: "done", code: 0 });
813
- }
814
- } else if (session.destroying) {
815
- console.log("[sdk-bridge] Suppressing worker error during shutdown for session " + session.localId);
816
- } else {
817
- var errDetail = msg.error || "Unknown error";
818
- if (msg.stderr) errDetail += "\nstderr: " + msg.stderr;
819
- if (msg.exitCode != null) errDetail += " (exitCode: " + msg.exitCode + ")";
820
- console.error("[sdk-bridge] Worker query error for session " + session.localId + ":", errDetail);
821
-
822
- var errLower = errDetail.toLowerCase();
823
- var isContextOverflow = errLower.indexOf("prompt is too long") !== -1
824
- || errLower.indexOf("context_length") !== -1
825
- || errLower.indexOf("maximum context length") !== -1;
826
- var isAuthError = errLower.indexOf("not logged in") !== -1
827
- || errLower.indexOf("unauthenticated") !== -1
828
- || errLower.indexOf("authentication") !== -1
829
- || errLower.indexOf("sign in") !== -1
830
- || errLower.indexOf("log in") !== -1
831
- || errLower.indexOf("please login") !== -1;
832
- if (isContextOverflow) {
833
- sendAndRecord(session, { type: "context_overflow", text: "Conversation too long to continue." });
834
- } else if (isAuthError) {
835
- var authUser = session.ownerId ? usersModule.findUserById(session.ownerId) : null;
836
- var authLinuxUser = authUser && authUser.linuxUser ? authUser.linuxUser : null;
837
- // Determine if auto-login (auto terminal + claude) is safe:
838
- // - Single-user mode: always ok
839
- // - Multi-user + OS user isolation (linuxUser set): ok (isolated)
840
- // - Multi-user + admin role: ok (they own the shared account)
841
- // - Multi-user + regular user (no linuxUser): not ok (shared account)
842
- var canAutoLogin = !usersModule.isMultiUser()
843
- || !!authLinuxUser
844
- || (authUser && authUser.role === "admin");
845
- sendAndRecord(session, {
846
- type: "auth_required",
847
- text: "Claude Code is not logged in.",
848
- linuxUser: authLinuxUser,
849
- canAutoLogin: canAutoLogin,
850
- });
851
- } else {
852
- var errText = msg.error || "Unknown error";
853
- // When stderr is empty, fall back to worker stderr buffer (covers hook failures at session start)
854
- if (!msg.stderr && worker._stderrBuf) {
855
- errText += "\n" + worker._stderrBuf.trim();
856
- }
857
- sendAndRecord(session, { type: "error", text: "Claude process error: " + errText });
858
- }
859
- sendAndRecord(session, { type: "done", code: 1 });
860
- }
861
- sm.broadcastSessionList();
862
- }
863
- cleanupSessionWorker(session, worker);
864
- // Mark session as done so late rate_limit_event can detect race condition
865
- session.isProcessing = false;
866
- // Auto-continue on rate limit (scheduler sessions, or user setting)
867
- var workerDidScheduleAC = false;
868
- var workerACEnabled = session.onQueryComplete || (typeof opts.getAutoContinueSetting === "function" && opts.getAutoContinueSetting(session));
869
- if (session.rateLimitResetsAt && session.rateLimitResetsAt > Date.now()
870
- && workerACEnabled && !session.destroying) {
871
- var wacResetsAt = session.rateLimitResetsAt;
872
- session.rateLimitResetsAt = null;
873
- session.rateLimitAutoContinuePending = true;
874
- workerDidScheduleAC = true;
875
- console.log("[sdk-bridge] Rate limited (worker), scheduling auto-continue via scheduleMessage for session " + session.localId);
876
- if (typeof opts.scheduleMessage === "function") {
877
- opts.scheduleMessage(session, "continue", wacResetsAt);
878
- }
879
- }
880
- if (session.onQueryComplete && !workerDidScheduleAC) {
881
- try { session.onQueryComplete(session); } catch (err) {
882
- console.error("[sdk-bridge] onQueryComplete error:", err.message || err);
883
- }
884
- }
885
- break;
886
- }
887
-
888
- case "model_changed":
889
- sm.currentModel = msg.model;
890
- send({ type: "model_info", model: msg.model, models: sm.availableModels || [] });
891
- send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
892
- break;
893
-
894
- case "effort_changed":
895
- sm.currentEffort = msg.effort;
896
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
897
- break;
898
-
899
- case "permission_mode_changed":
900
- sm.currentPermissionMode = msg.mode;
901
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
902
- break;
903
-
904
- case "worker_error":
905
- send({ type: "error", text: msg.error });
906
- break;
907
- }
908
- });
909
-
910
- // Wait for worker to be ready, then send query
911
- if (!reusingWorker) {
912
- perf("awaiting readyPromise");
913
- try {
914
- await worker.readyPromise;
915
- perf("readyPromise resolved");
916
- } catch (e) {
917
- session.isProcessing = false;
918
- onProcessingChanged();
919
- sendAndRecord(session, { type: "error", text: "Worker failed to connect: " + (e.message || e) });
920
- sendAndRecord(session, { type: "done", code: 1 });
921
- sm.broadcastSessionList();
922
- killSessionWorker(session);
923
- return;
924
- }
925
- }
926
-
927
- perf("sending query_start to worker");
928
- worker.send({
929
- type: "query_start",
930
- prompt: initialMessage,
931
- options: queryOptions,
932
- mcpDescriptors: _mcpDescriptors,
933
- singleTurn: !!session.singleTurn,
934
- originalHome: require("./config").REAL_HOME || null,
935
- projectPath: session.cwd || null,
936
- _perfT0: t0,
937
- });
938
- perf("query_start sent");
939
- }
940
-
941
- function cleanupSessionWorker(session, fromWorker) {
942
- console.log("[sdk-bridge] cleanupSessionWorker() called, localId=" + session.localId +
943
- " fromWorkerPid=" + (fromWorker && fromWorker.process ? fromWorker.process.pid : "none") +
944
- " currentWorkerPid=" + (session.worker && session.worker.process ? session.worker.process.pid : "none") +
945
- " stack=" + new Error().stack.split("\n").slice(1, 4).join(" | "));
946
- // If called from a specific worker's exit/error handler, only cleanup if
947
- // that worker is still the session's current worker. Prevents stale
948
- // worker exit events from killing a newer worker.
949
- if (fromWorker && session.worker && session.worker !== fromWorker) {
950
- console.log("[sdk-bridge] cleanupSessionWorker: stale worker guard triggered, skipping");
951
- return;
952
- }
953
- session.queryInstance = null;
954
- session.messageQueue = null;
955
- session.abortController = null;
956
- session.taskStopRequested = false;
957
- session.pendingPermissions = {};
958
- session.pendingAskUser = {};
959
- session.pendingElicitations = {};
960
- // Keep the worker alive between queries so the SDK can maintain session
961
- // state in memory. Killing the worker after each query forces resume from
962
- // disk, but the SDK may not save the session file on abort, causing
963
- // "no conversation found" and losing all conversation history.
964
- // The worker is only killed when the UI session is destroyed or on error.
965
- }
966
-
967
- // Force-kill the worker and remove it from the session.
968
- // Used when the session is destroyed or on unrecoverable errors.
969
- function killSessionWorker(session) {
970
- if (session.worker) {
971
- session._workerExitPromise = session.worker.exitPromise;
972
- session.worker.kill();
973
- session.worker = null;
974
- }
975
- }
976
-
977
- /**
978
- * Run warmup via a worker process for a specific Linux user.
979
- */
980
- async function warmupViaWorker(linuxUser) {
981
- var worker;
982
- try {
983
- worker = spawnWorker(linuxUser);
984
- } catch (e) {
985
- send({ type: "error", text: "Failed to spawn warmup worker for " + linuxUser + ": " + (e.message || e) });
986
- return;
987
- }
988
-
989
- var warmupDone = false;
990
-
991
- worker.onMessage(function(msg) {
992
- if (msg.type === "warmup_done" && !warmupDone) {
993
- warmupDone = true;
994
- var result = msg.result || {};
995
- var fsSkills = discoverSkillDirs();
996
- sm.skillNames = mergeSkills(result.skills, fsSkills);
997
- if (result.slashCommands) {
998
- var seen = new Set();
999
- var combined = [];
1000
- var all = result.slashCommands.concat(Array.from(sm.skillNames));
1001
- for (var k = 0; k < all.length; k++) {
1002
- if (!seen.has(all[k])) {
1003
- seen.add(all[k]);
1004
- combined.push(all[k]);
1005
- }
307
+ // Pre-copy CLI session file so the worker can resume the conversation
308
+ if (session.cliSessionId) {
309
+ var sessionFileName = session.cliSessionId + ".jsonl";
310
+ var srcFile = path.join(originalHome, ".claude", "projects", projectSlug, sessionFileName);
311
+ var dstFile = path.join(dstDir, sessionFileName);
312
+ if (fs.existsSync(srcFile) && !fs.existsSync(dstFile)) {
313
+ fs.copyFileSync(srcFile, dstFile);
314
+ try { require("child_process").execSync("chown " + uid + " " + JSON.stringify(dstFile)); } catch (e2) {}
315
+ console.log("[sdk-bridge] Pre-copied CLI session " + session.cliSessionId + " to " + linuxUser);
1006
316
  }
1007
- sm.slashCommands = combined;
1008
- send({ type: "slash_commands", commands: sm.slashCommands });
1009
317
  }
1010
- if (result.model) {
1011
- sm.currentModel = sm._savedDefaultModel || result.model;
1012
- }
1013
- sm.availableModels = result.models || [];
1014
- send({ type: "model_info", model: sm.currentModel || "", models: sm.availableModels || [] });
1015
- worker.kill();
1016
- } else if (msg.type === "warmup_error" && !warmupDone) {
1017
- warmupDone = true;
1018
- send({ type: "error", text: msg.error || "Warmup failed" });
1019
- worker.kill();
1020
318
  }
1021
- });
1022
-
1023
- try {
1024
- await worker.readyPromise;
1025
- } catch (e) {
1026
- send({ type: "error", text: "Warmup worker failed to connect: " + (e.message || e) });
1027
- cleanupWorker(worker);
1028
- return;
319
+ } catch (copyErr) {
320
+ console.log("[sdk-bridge] Dir setup / session pre-copy skipped:", copyErr.message);
1029
321
  }
1030
-
1031
- var warmupOptions = { cwd: cwd, settingSources: ["user", "project", "local"], settings: { disableAllHooks: true } };
1032
- if (dangerouslySkipPermissions) {
1033
- warmupOptions.permissionMode = "bypassPermissions";
1034
- warmupOptions.allowDangerouslySkipPermissions = true;
1035
- }
1036
- worker.send({ type: "warmup", options: warmupOptions });
1037
322
  }
1038
323
 
1039
324
  // --- SDK query lifecycle ---
@@ -1235,6 +520,7 @@ function createSDKBridge(opts) {
1235
520
  toolInput: input,
1236
521
  toolUseId: opts.toolUseID,
1237
522
  decisionReason: opts.decisionReason || "",
523
+ vendor: session.vendor || (adapter && adapter.vendor) || "claude",
1238
524
  };
1239
525
  sendAndRecord(session, permMsg);
1240
526
  onProcessingChanged(); // update cross-project permission badge
@@ -1338,19 +624,55 @@ function createSDKBridge(opts) {
1338
624
  // Capture references at start so we only clean up OUR resources in finally,
1339
625
  // not resources from a newer query that may have been created after an abort.
1340
626
  var myQueryInstance = session.queryInstance;
1341
- var myMessageQueue = session.messageQueue;
1342
627
  var myAbortController = session.abortController;
628
+ console.log("[sdk-bridge] processQueryStream: starting for-await loop, vendor=" + (session.vendor || adapter.vendor));
1343
629
  try {
1344
630
  for await (var msg of myQueryInstance) {
631
+ if (msg && msg.yokeType !== "text_delta" && msg.yokeType !== "thinking_delta" && msg.yokeType !== "tool_input_delta") {
632
+ console.log("[sdk-bridge] processQueryStream: received event yokeType=" + msg.yokeType);
633
+ }
634
+ // Handle worker meta events (context_usage, model_changed, etc.)
635
+ if (msg && msg.type === "_worker_meta") {
636
+ var metaData = msg.data || {};
637
+ switch (msg.subtype) {
638
+ case "context_usage":
639
+ session.lastContextUsage = metaData.data;
640
+ sendToSession(session, { type: "context_usage", data: metaData.data });
641
+ break;
642
+ case "model_changed":
643
+ sm.currentModel = metaData.model;
644
+ send({ type: "model_info", model: metaData.model, models: sm.availableModels || [], vendor: adapter.vendor });
645
+ send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
646
+ break;
647
+ case "effort_changed":
648
+ sm.currentEffort = metaData.effort;
649
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
650
+ break;
651
+ case "permission_mode_changed":
652
+ sm.currentPermissionMode = metaData.mode;
653
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
654
+ break;
655
+ case "worker_error":
656
+ send({ type: "error", text: metaData.error });
657
+ break;
658
+ }
659
+ continue;
660
+ }
1345
661
  processSDKMessage(session, msg);
1346
662
  }
1347
663
  // (getContextUsage moved to processSDKMessage result handler -- fire-and-forget)
1348
664
  // Stream ended normally after a task stop — no "result" message was sent,
1349
665
  // so the session is still marked as processing. Send interrupted feedback.
666
+ console.log("[sdk-bridge] processQueryStream ended: isProcessing=" + session.isProcessing + " taskStopRequested=" + session.taskStopRequested);
1350
667
  if (session.isProcessing && session.taskStopRequested) {
1351
668
  session.isProcessing = false;
1352
669
  onProcessingChanged();
1353
- sendAndRecord(session, { type: "info", text: "Interrupted \u00b7 What should Claude do instead?" });
670
+ send({ type: "status", processing: false });
671
+ sendAndRecord(session, { type: "thinking_stop" });
672
+ var interruptMsg = (session.vendor === "codex")
673
+ ? "\u25a0 Conversation interrupted - tell the model what to do differently."
674
+ : "Interrupted \u00b7 What should Claude do instead?";
675
+ sendAndRecord(session, { type: "info", text: interruptMsg });
1354
676
  sendAndRecord(session, { type: "done", code: 0 });
1355
677
  sm.broadcastSessionList();
1356
678
  }
@@ -1360,7 +682,11 @@ function createSDKBridge(opts) {
1360
682
  onProcessingChanged();
1361
683
  if (err.name === "AbortError" || (myAbortController && myAbortController.signal.aborted) || session.taskStopRequested) {
1362
684
  if (!session.destroying) {
1363
- sendAndRecord(session, { type: "info", text: "Interrupted \u00b7 What should Claude do instead?" });
685
+ sendAndRecord(session, { type: "thinking_stop" });
686
+ var interruptMsg2 = (session.vendor === "codex")
687
+ ? "\u25a0 Conversation interrupted - tell the model what to do differently."
688
+ : "Interrupted \u00b7 What should Claude do instead?";
689
+ sendAndRecord(session, { type: "info", text: interruptMsg2 });
1364
690
  sendAndRecord(session, { type: "done", code: 0 });
1365
691
  }
1366
692
  } else if (session.destroying) {
@@ -1433,7 +759,7 @@ function createSDKBridge(opts) {
1433
759
  } catch (e) {}
1434
760
  session.queryInstance = null;
1435
761
  }
1436
- if (session.messageQueue === myMessageQueue) session.messageQueue = null;
762
+ session.messageQueue = null;
1437
763
  if (session.abortController === myAbortController) session.abortController = null;
1438
764
  session.taskStopRequested = false;
1439
765
  session.pendingPermissions = {};
@@ -1478,58 +804,157 @@ function createSDKBridge(opts) {
1478
804
  async function getOrCreateRewindQuery(session) {
1479
805
  if (session.queryInstance) return { query: session.queryInstance, isTemp: false, cleanup: function() {} };
1480
806
 
1481
- var sdk;
807
+ var handle;
1482
808
  try {
1483
- sdk = await getSDK();
809
+ handle = await adapter.createQuery({
810
+ cwd: cwd,
811
+ resumeSessionId: session.cliSessionId,
812
+ adapterOptions: {
813
+ CLAUDE: {
814
+ settingSources: ["user", "project", "local"],
815
+ enableFileCheckpointing: true,
816
+ },
817
+ },
818
+ });
1484
819
  } catch (e) {
1485
820
  sendAndRecord(session, { type: "error", text: "Failed to load Claude SDK: " + (e.message || e) });
1486
821
  throw e;
1487
822
  }
1488
- var mq = createMessageQueue();
1489
823
 
1490
- var tempQuery = sdk.query({
1491
- prompt: mq,
1492
- options: {
1493
- cwd: cwd,
1494
- settingSources: ["user", "project", "local"],
1495
- enableFileCheckpointing: true,
1496
- resume: session.cliSessionId,
1497
- },
1498
- });
1499
-
1500
- // Drain messages in background (stream stays alive until mq.end())
824
+ // Drain messages in background (stream stays alive until close)
1501
825
  (async function() {
1502
- try { for await (var msg of tempQuery) {} } catch(e) {}
826
+ try { for await (var msg of handle) {} } catch(e) {}
1503
827
  })();
1504
828
 
1505
829
  return {
1506
- query: tempQuery,
830
+ query: handle,
1507
831
  isTemp: true,
1508
- cleanup: function() { try { mq.end(); } catch(e) {} },
832
+ cleanup: function() { try { handle.close(); } catch(e) {} },
1509
833
  };
1510
834
  }
1511
835
 
1512
- async function startQuery(session, text, images, linuxUser) {
1513
- // Remember linuxUser for auto-continue after rate limit
1514
- session.lastLinuxUser = linuxUser || null;
1515
- // OS-level isolation: delegate to worker process if linuxUser is set
1516
- if (linuxUser) {
1517
- return startQueryViaWorker(session, text, images, linuxUser);
836
+ // --- Unified rewind/fork interface (adapter-agnostic) ---
837
+
838
+ async function rewindPreview(session, uuid) {
839
+ var sessionAdapter = getAdapterForSession(session);
840
+ // Adapters with rollbackThread (e.g. Codex) do chat-only rewind, no file diffs
841
+ if (sessionAdapter && typeof sessionAdapter.rollbackThread === "function") {
842
+ return { preview: { filesChanged: [] }, diffs: {}, chatOnly: true };
1518
843
  }
844
+ // Claude path: use rewindFiles with dryRun
845
+ var result = await getOrCreateRewindQuery(session);
846
+ try {
847
+ var preview = await result.query.rewindFiles(uuid, { dryRun: true });
848
+ var diffs = {};
849
+ var changedFiles = preview.filesChanged || [];
850
+ for (var f = 0; f < changedFiles.length; f++) {
851
+ try {
852
+ diffs[changedFiles[f]] = require("child_process").execFileSync(
853
+ "git", ["diff", "HEAD", "--", changedFiles[f]],
854
+ { cwd: cwd, encoding: "utf8", timeout: 5000 }
855
+ ) || "";
856
+ } catch (e) { diffs[changedFiles[f]] = ""; }
857
+ }
858
+ return { preview: preview, diffs: diffs, chatOnly: false };
859
+ } finally {
860
+ if (result.isTemp) result.cleanup();
861
+ }
862
+ }
1519
863
 
1520
- var sdk;
864
+ async function rewindExecuteFiles(session, uuid) {
865
+ var sessionAdapter = getAdapterForSession(session);
866
+ // Adapters with rollbackThread skip file restoration
867
+ if (sessionAdapter && typeof sessionAdapter.rollbackThread === "function") return;
868
+ // Claude path: restore files
869
+ var result = await getOrCreateRewindQuery(session);
1521
870
  try {
1522
- sdk = await getSDK();
1523
- } catch (e) {
1524
- session.isProcessing = false;
1525
- onProcessingChanged();
1526
- sendAndRecord(session, { type: "error", text: "Failed to load Claude SDK: " + (e.message || e) });
871
+ await result.query.rewindFiles(uuid, { dryRun: false });
872
+ } finally {
873
+ if (result.isTemp) result.cleanup();
874
+ }
875
+ }
876
+
877
+ async function rollbackConversation(session, numTurns) {
878
+ var sessionAdapter = getAdapterForSession(session);
879
+ if (sessionAdapter && typeof sessionAdapter.rollbackThread === "function") {
880
+ await sessionAdapter.rollbackThread(session.cliSessionId, numTurns);
881
+ }
882
+ // Claude: conversation rollback is handled by rewindFiles + local history trim
883
+ }
884
+
885
+ function getAdapterForSession(session) {
886
+ var vendor = session.vendor || sm.defaultVendor || "claude";
887
+ return adapters[vendor] || adapter;
888
+ }
889
+
890
+ async function forkSessionUnified(session, uuid) {
891
+ var sessionAdapter = getAdapterForSession(session);
892
+ var result = await sessionAdapter.forkSession(session.cliSessionId, { upToMessageId: uuid, dir: cwd });
893
+ if (!result || !result.sessionId) throw new Error("Fork returned no session id");
894
+
895
+ // Adapters with rollbackThread (e.g. Codex) use local history copy
896
+ if (typeof sessionAdapter.rollbackThread === "function") {
897
+ return { sessionId: result.sessionId, useLocalHistory: true };
898
+ }
899
+ // Claude: read history from CLI session files
900
+ return { sessionId: result.sessionId, useLocalHistory: false };
901
+ }
902
+
903
+ async function startQuery(session, text, images, linuxUser) {
904
+ // If vendor is set but adapter not ready, try lazy creation (user may have logged in)
905
+ if (session.vendor && !adapters[session.vendor]) {
906
+ var yoke = require("./yoke");
907
+ var lazyAdapter = yoke.lazyCreateAdapter(adapters, session.vendor, { cwd: cwd });
908
+ if (lazyAdapter) {
909
+ console.log("[sdk-bridge] Lazy adapter created for " + session.vendor);
910
+ }
911
+ }
912
+ // If still not available after lazy check, send auth_required
913
+ if (session.vendor && !adapters[session.vendor]) {
914
+ var vendorName = session.vendor.charAt(0).toUpperCase() + session.vendor.slice(1);
915
+ var authUser = session.ownerId ? usersModule.findUserById(session.ownerId) : null;
916
+ var authLinuxUser = authUser && authUser.linuxUser ? authUser.linuxUser : null;
917
+ var canAutoLogin = !usersModule.isMultiUser()
918
+ || !!authLinuxUser
919
+ || (authUser && authUser.role === "admin");
920
+ sendAndRecord(session, {
921
+ type: "auth_required",
922
+ text: vendorName + " is not logged in.",
923
+ vendor: session.vendor,
924
+ loginCommand: session.vendor === "codex" ? "codex --login" : session.vendor + " login",
925
+ linuxUser: authLinuxUser,
926
+ canAutoLogin: canAutoLogin,
927
+ });
1527
928
  sendAndRecord(session, { type: "done", code: 1 });
1528
- sm.broadcastSessionList();
1529
929
  return;
1530
930
  }
931
+ // Select adapter based on session vendor (fallback to default)
932
+ var sessionAdapter = (session.vendor && adapters[session.vendor]) || adapter;
933
+ console.log("[sdk-bridge] startQuery: vendor=" + sessionAdapter.vendor + " session=" + session.localId + " text=" + (text || "").substring(0, 50));
934
+ // Remember linuxUser for auto-continue after rate limit
935
+ session.lastLinuxUser = linuxUser || null;
936
+
937
+ var t0 = session._queryStartTs || Date.now();
938
+
939
+ // Wait for previous worker to fully exit before spawning a new one.
940
+ // Without this, the new worker may try to resume the SDK session file
941
+ // while the old worker is still flushing it to disk, causing
942
+ // "no conversation found" and losing all prior context.
943
+ // Harmless if null (no previous worker).
944
+ if (session._workerExitPromise) {
945
+ var exitWait = session._workerExitPromise;
946
+ session._workerExitPromise = null;
947
+ await Promise.race([
948
+ exitWait,
949
+ new Promise(function(resolve) { setTimeout(resolve, 3000); }),
950
+ ]);
951
+ }
952
+
953
+ // Ensure Linux user project directory exists (runs in parallel with worker boot)
954
+ if (linuxUser) {
955
+ ensureLinuxUserProjectDir(linuxUser, session);
956
+ }
1531
957
 
1532
- session.messageQueue = createMessageQueue();
1533
958
  session.blocks = {};
1534
959
  session.sentToolResults = {};
1535
960
  session.activeTaskToolIds = {};
@@ -1537,106 +962,118 @@ function createSDKBridge(opts) {
1537
962
  session.streamedText = false;
1538
963
  session.responsePreview = "";
1539
964
 
1540
- // Build initial user message
1541
- var content = [];
1542
- if (images && images.length > 0) {
1543
- for (var i = 0; i < images.length; i++) {
1544
- content.push({
1545
- type: "image",
1546
- source: { type: "base64", media_type: images[i].mediaType, data: images[i].data },
1547
- });
1548
- }
1549
- }
1550
- if (text) {
1551
- content.push({ type: "text", text: text });
965
+ // For in-process path, create AbortController. For worker path, the adapter
966
+ // handles abort internally and exposes it via handle.abort().
967
+ if (!linuxUser) {
968
+ session.abortController = new AbortController();
1552
969
  }
1553
970
 
1554
- session.messageQueue.push({
1555
- type: "user",
1556
- message: { role: "user", content: content },
1557
- });
1558
-
1559
- session.abortController = new AbortController();
1560
-
1561
- var queryOptions = {
1562
- cwd: cwd,
971
+ // Build Claude-specific adapter options
972
+ var claudeOpts = {
1563
973
  settingSources: ["user", "project", "local"],
1564
974
  includePartialMessages: true,
1565
975
  enableFileCheckpointing: true,
1566
976
  extraArgs: { "replay-user-messages": null },
1567
- abortController: session.abortController,
1568
977
  promptSuggestions: true,
1569
978
  agentProgressSummaries: true,
1570
- mcpServers: mergeMcpServers(mcpServers, getRemoteMcpServers) || undefined,
1571
- canUseTool: function(toolName, input, toolOpts) {
1572
- return handleCanUseTool(session, toolName, input, toolOpts);
1573
- },
1574
- onElicitation: function(request, elicitOpts) {
1575
- return handleElicitation(session, request, elicitOpts);
1576
- },
1577
979
  };
1578
980
 
1579
981
  // Per-loop settings override global defaults when present
1580
982
  var ls = session.loopSettings || {};
1581
983
 
1582
- if (ls.model || sm.currentModel) {
1583
- queryOptions.model = ls.model || sm.currentModel;
1584
- }
1585
-
1586
- if (ls.effort || sm.currentEffort) {
1587
- queryOptions.effort = ls.effort || sm.currentEffort;
1588
- }
1589
-
1590
984
  if (sm.currentBetas && sm.currentBetas.length > 0) {
1591
- queryOptions.betas = sm.currentBetas;
985
+ claudeOpts.betas = sm.currentBetas;
1592
986
  }
1593
-
1594
987
  var thinkingMode = ls.thinking || sm.currentThinking;
1595
988
  if (thinkingMode === "disabled") {
1596
- queryOptions.thinking = { type: "disabled" };
989
+ claudeOpts.thinking = { type: "disabled" };
1597
990
  } else if (thinkingMode === "budget") {
1598
991
  var budgetTokens = ls.thinkingBudget || sm.currentThinkingBudget;
1599
- if (budgetTokens) queryOptions.thinking = { type: "enabled", budgetTokens: budgetTokens };
992
+ if (budgetTokens) claudeOpts.thinking = { type: "enabled", budgetTokens: budgetTokens };
1600
993
  }
1601
994
 
1602
995
  if (ls.permissionMode) {
1603
- // Will be applied below, store for later
1604
996
  session._loopPermissionMode = ls.permissionMode;
1605
997
  }
1606
998
 
1607
999
  // Pass through any extra SDK settings from LOOP.json
1608
1000
  if (ls.disableAllHooks !== undefined) {
1609
- queryOptions.settings = Object.assign({}, queryOptions.settings || {}, { disableAllHooks: ls.disableAllHooks });
1001
+ claudeOpts.settings = Object.assign({}, claudeOpts.settings || {}, { disableAllHooks: ls.disableAllHooks });
1610
1002
  }
1611
1003
 
1612
1004
  if (dangerouslySkipPermissions) {
1613
- queryOptions.allowDangerouslySkipPermissions = true;
1005
+ claudeOpts.allowDangerouslySkipPermissions = true;
1614
1006
  }
1615
- // Pass permissionMode in queryOptions at creation time to avoid race condition
1616
1007
  var modeToApply = session._loopPermissionMode || (session.acceptEditsAfterStart ? "acceptEdits" : sm.currentPermissionMode);
1617
1008
  if (session.acceptEditsAfterStart) delete session.acceptEditsAfterStart;
1618
1009
  if (modeToApply && modeToApply !== "default") {
1619
- queryOptions.permissionMode = modeToApply;
1010
+ claudeOpts.permissionMode = modeToApply;
1011
+ }
1012
+ if (session.cliSessionId && session.lastRewindUuid) {
1013
+ claudeOpts.resumeSessionAt = session.lastRewindUuid;
1014
+ delete session.lastRewindUuid;
1015
+ sm.saveSessionFile(session);
1620
1016
  }
1621
1017
 
1622
- if (session.cliSessionId) {
1623
- queryOptions.resume = session.cliSessionId;
1624
- if (session.lastRewindUuid) {
1625
- queryOptions.resumeSessionAt = session.lastRewindUuid;
1626
- delete session.lastRewindUuid;
1627
- // Persist the deletion so server restarts don't re-use a stale UUID
1628
- sm.saveSessionFile(session);
1018
+ // Pass linuxUser to adapter for worker-based queries
1019
+ if (linuxUser) {
1020
+ claudeOpts.linuxUser = linuxUser;
1021
+ claudeOpts.singleTurn = !!session.singleTurn;
1022
+ claudeOpts.originalHome = require("./config").REAL_HOME || null;
1023
+ claudeOpts.projectPath = session.cwd || null;
1024
+ claudeOpts._perfT0 = t0;
1025
+ // Pass previous worker state for reuse
1026
+ if (session._adapterWorkerState) {
1027
+ claudeOpts._workerState = session._adapterWorkerState;
1028
+ session._adapterWorkerState = null;
1029
+ }
1030
+ }
1031
+
1032
+ // Use vendor-specific model: if session vendor differs from default, use that vendor's default model
1033
+ var queryModel = ls.model || sm.currentModel || undefined;
1034
+ if (session.vendor && session.vendor !== (adapter && adapter.vendor)) {
1035
+ var vendorModels = (sm.modelsByVendor && sm.modelsByVendor[session.vendor]) || [];
1036
+ if (vendorModels.length > 0 && queryModel && vendorModels.indexOf(queryModel) === -1) {
1037
+ queryModel = vendorModels[0];
1629
1038
  }
1630
1039
  }
1631
1040
 
1041
+ var codexConfig = getCodexConfig(sm);
1042
+ var queryOpts = {
1043
+ cwd: cwd,
1044
+ model: queryModel,
1045
+ effort: ls.effort || sm.currentEffort || undefined,
1046
+ toolServers: mergeMcpServers(mcpServers, getRemoteMcpServers) || undefined,
1047
+ resumeSessionId: session.cliSessionId || undefined,
1048
+ abortController: linuxUser ? undefined : session.abortController,
1049
+ canUseTool: function(toolName, input, toolOpts) {
1050
+ return handleCanUseTool(session, toolName, input, toolOpts);
1051
+ },
1052
+ onElicitation: function(request, elicitOpts) {
1053
+ return handleElicitation(session, request, elicitOpts);
1054
+ },
1055
+ adapterOptions: {
1056
+ CLAUDE: claudeOpts,
1057
+ CODEX: {
1058
+ // Always use "never" (auto-approve) because Clay handles tool
1059
+ // permissions via its own UI (checkToolWhitelist + handleCanUseTool).
1060
+ // Codex's native approval prompts are terminal-based and cannot be
1061
+ // relayed through Clay's web UI, causing MCP tool calls to hang.
1062
+ approvalPolicy: "never",
1063
+ sandboxMode: codexConfig.sandbox,
1064
+ webSearchMode: codexConfig.webSearch,
1065
+ },
1066
+ },
1067
+ };
1068
+
1069
+ var handle;
1070
+ console.log("[sdk-bridge] calling adapter.createQuery... vendor=" + sessionAdapter.vendor);
1632
1071
  try {
1633
- session.queryInstance = sdk.query({
1634
- prompt: session.messageQueue,
1635
- options: queryOptions,
1636
- });
1072
+ handle = await sessionAdapter.createQuery(queryOpts);
1073
+ console.log("[sdk-bridge] createQuery returned handle, vendor=" + sessionAdapter.vendor);
1637
1074
  } catch (e) {
1638
1075
  console.error("[sdk-bridge] Failed to create query for session " + session.localId + ":", e.message || e);
1639
- console.error("[sdk-bridge] cliSessionId:", session.cliSessionId, "resume:", !!queryOptions.resume);
1076
+ console.error("[sdk-bridge] cliSessionId:", session.cliSessionId, "resume:", !!session.cliSessionId);
1640
1077
  console.error("[sdk-bridge] Stack:", e.stack || "(no stack)");
1641
1078
  session.isProcessing = false;
1642
1079
  onProcessingChanged();
@@ -1649,12 +1086,38 @@ function createSDKBridge(opts) {
1649
1086
  return;
1650
1087
  }
1651
1088
 
1089
+ // Store adapter worker state for reuse on next query
1090
+ if (handle._adapterState) {
1091
+ session._adapterWorkerState = handle._adapterState;
1092
+ // Keep session.worker reference for external code (sessions.js, project.js)
1093
+ // that needs to kill the worker on session destroy.
1094
+ if (handle._adapterState.worker) {
1095
+ session.worker = handle._adapterState.worker;
1096
+ }
1097
+ }
1098
+
1099
+ // For worker path, create an abortController wrapper that delegates to handle.abort()
1100
+ if (linuxUser) {
1101
+ session.abortController = {
1102
+ abort: function() { handle.abort(); },
1103
+ signal: { aborted: false, addEventListener: function() {} },
1104
+ };
1105
+ }
1106
+
1107
+ // Store QueryHandle on session for iteration and control.
1108
+ session.queryInstance = handle;
1109
+
1110
+ // Push initial user message through the QueryHandle
1111
+ console.log("[sdk-bridge] pushing initial message via handle.pushMessage...");
1112
+ handle.pushMessage(text, images);
1113
+ console.log("[sdk-bridge] pushMessage done, starting processQueryStream...");
1114
+
1652
1115
  // For single-turn sessions (Ralph Loop), end the message queue so the SDK
1653
1116
  // query finishes after processing the one message. Without this, the query
1654
1117
  // stream stays open forever waiting for more messages, and onQueryComplete
1655
1118
  // never fires.
1656
1119
  if (session.singleTurn) {
1657
- session.messageQueue.end();
1120
+ handle.endInput();
1658
1121
  }
1659
1122
 
1660
1123
  session.lastActivityAt = Date.now();
@@ -1663,28 +1126,10 @@ function createSDKBridge(opts) {
1663
1126
  }
1664
1127
 
1665
1128
  function pushMessage(session, text, images) {
1666
- var content = [];
1667
- if (images && images.length > 0) {
1668
- for (var i = 0; i < images.length; i++) {
1669
- content.push({
1670
- type: "image",
1671
- source: { type: "base64", media_type: images[i].mediaType, data: images[i].data },
1672
- });
1673
- }
1674
- }
1675
- if (text) {
1676
- content.push({ type: "text", text: text });
1677
- }
1678
- var userMsg = {
1679
- type: "user",
1680
- message: { role: "user", content: content },
1681
- };
1682
1129
  session.lastActivityAt = Date.now();
1683
- // Route through worker if active, otherwise direct to message queue
1684
- if (session.worker) {
1685
- session.worker.send({ type: "push_message", content: userMsg });
1686
- } else {
1687
- session.messageQueue.push(userMsg);
1130
+ // Route through QueryHandle (works for both in-process and worker paths)
1131
+ if (session.queryInstance && typeof session.queryInstance.pushMessage === "function") {
1132
+ session.queryInstance.pushMessage(text, images);
1688
1133
  }
1689
1134
  }
1690
1135
 
@@ -1731,74 +1176,126 @@ function createSDKBridge(opts) {
1731
1176
  return text;
1732
1177
  }
1733
1178
 
1734
- // SDK warmup: grab slash_commands, model, and available models from SDK init
1735
- async function warmup(linuxUser) {
1736
- // OS-level isolation: delegate warmup to worker process
1737
- if (linuxUser) {
1738
- return warmupViaWorker(linuxUser);
1739
- }
1179
+ // Detect which vendor binaries are installed for this user.
1180
+ // In multi-user mode, runs checks as the specific Linux user.
1181
+ function detectInstalledVendors(linuxUser) {
1182
+ var execSync = require("child_process").execSync;
1183
+ var fs = require("fs");
1184
+ var path = require("path");
1185
+ var result = [];
1740
1186
 
1741
- try {
1742
- var sdk = await getSDK();
1743
- var ac = new AbortController();
1744
- var mq = createMessageQueue();
1745
- mq.push({ type: "user", message: { role: "user", content: [{ type: "text", text: "hi" }] } });
1746
- mq.end();
1747
- var warmupOptions = { cwd: cwd, settingSources: ["user", "project", "local"], abortController: ac, settings: { disableAllHooks: true } };
1748
- if (dangerouslySkipPermissions) {
1749
- warmupOptions.permissionMode = "bypassPermissions";
1750
- warmupOptions.allowDangerouslySkipPermissions = true;
1187
+ function tryExec(cmd) {
1188
+ try {
1189
+ if (linuxUser) {
1190
+ execSync("su - " + linuxUser + " -c " + JSON.stringify(cmd), { timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
1191
+ } else {
1192
+ execSync(cmd, { timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
1193
+ }
1194
+ return true;
1195
+ } catch (e) {
1196
+ return false;
1751
1197
  }
1752
- var stream = sdk.query({
1753
- prompt: mq,
1754
- options: warmupOptions,
1755
- });
1756
- for await (var msg of stream) {
1757
- if (msg.type === "system" && msg.subtype === "init") {
1758
- var fsSkills = discoverSkillDirs();
1759
- sm.skillNames = mergeSkills(msg.skills, fsSkills);
1760
- if (msg.slash_commands) {
1761
- // Union: SDK slash_commands + merged skills (deduplicated)
1762
- var seen = new Set();
1763
- var combined = [];
1764
- var all = msg.slash_commands.concat(Array.from(sm.skillNames));
1765
- for (var k = 0; k < all.length; k++) {
1766
- if (!seen.has(all[k])) {
1767
- seen.add(all[k]);
1768
- combined.push(all[k]);
1769
- }
1198
+ }
1199
+
1200
+ // Claude: check if binary is in PATH
1201
+ if (tryExec("which claude")) result.push("claude");
1202
+
1203
+ // Codex: check bundled binary or PATH
1204
+ var codexBin = path.join(__dirname, "../node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex/codex");
1205
+ if (fs.existsSync(codexBin) || tryExec("which codex")) result.push("codex");
1206
+
1207
+ return result;
1208
+ }
1209
+
1210
+ // SDK warmup: initialize all available adapters and collect models.
1211
+ // The default adapter is initialized first for slash_commands and skills.
1212
+ // Passes linuxUser to adapter for worker-based warmup when OS isolation is needed.
1213
+ async function warmup(linuxUser) {
1214
+ var defaultVendor = adapter ? adapter.vendor : "claude";
1215
+ sm.defaultVendor = defaultVendor;
1216
+
1217
+ // Initialize default adapter first (provides skills, slash commands, etc.)
1218
+ if (adapter) {
1219
+ try {
1220
+ var result = await adapter.init({
1221
+ cwd: cwd,
1222
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
1223
+ linuxUser: linuxUser || undefined,
1224
+ clayPort: clayPort,
1225
+ clayTls: clayTls,
1226
+ clayAuthToken: clayAuthToken,
1227
+ slug: slug,
1228
+ });
1229
+
1230
+ var fsSkills = discoverSkillDirs();
1231
+ sm.skillNames = mergeSkills(result.skills, fsSkills);
1232
+ if (result.slashCommands) {
1233
+ var seen = new Set();
1234
+ var combined = [];
1235
+ var all = result.slashCommands.concat(Array.from(sm.skillNames));
1236
+ for (var k = 0; k < all.length; k++) {
1237
+ if (!seen.has(all[k])) {
1238
+ seen.add(all[k]);
1239
+ combined.push(all[k]);
1770
1240
  }
1771
- sm.slashCommands = combined;
1772
- send({ type: "slash_commands", commands: sm.slashCommands });
1773
- }
1774
- if (msg.model) {
1775
- sm.currentModel = msg.model;
1776
1241
  }
1777
- // Fetch available models before aborting
1778
- try {
1779
- var models = await stream.supportedModels();
1780
- sm.availableModels = models || [];
1781
- } catch (e) {}
1782
- send({ type: "model_info", model: sm.currentModel || "", models: sm.availableModels || [] });
1783
- ac.abort();
1784
- break;
1242
+ sm.slashCommands = combined;
1243
+ sm.setSlashCommandsForVendor(defaultVendor, combined);
1244
+ send({ type: "slash_commands", commands: combined, vendor: defaultVendor });
1245
+ }
1246
+ if (result.defaultModel) {
1247
+ sm.currentModel = sm.currentModel || sm._savedDefaultModel || result.defaultModel;
1248
+ }
1249
+ sm.availableModels = result.models || [];
1250
+ // Store per-vendor models and capabilities
1251
+ sm.modelsByVendor = sm.modelsByVendor || {};
1252
+ sm.modelsByVendor[defaultVendor] = result.models || [];
1253
+ sm.capabilitiesByVendor = sm.capabilitiesByVendor || {};
1254
+ sm.capabilitiesByVendor[defaultVendor] = result.capabilities || {};
1255
+ } catch (e) {
1256
+ if (e && e.name !== "AbortError" && !(e.message && e.message.indexOf("aborted") !== -1)) {
1257
+ send({ type: "error", text: "Failed to load " + defaultVendor + " SDK: " + (e.message || e) });
1785
1258
  }
1786
1259
  }
1787
- } catch (e) {
1788
- if (e && e.name !== "AbortError" && !(e.message && e.message.indexOf("aborted") !== -1)) {
1789
- send({ type: "error", text: "Failed to load Claude SDK: " + (e.message || e) });
1790
- }
1791
1260
  }
1261
+
1262
+ // Initialize other adapters in parallel (skip if already have models cached)
1263
+ sm.modelsByVendor = sm.modelsByVendor || {};
1264
+ var otherVendors = Object.keys(adapters).filter(function(v) {
1265
+ return v !== defaultVendor && !sm.modelsByVendor[v];
1266
+ });
1267
+ for (var i = 0; i < otherVendors.length; i++) {
1268
+ (function(v) {
1269
+ adapters[v].init({ cwd: cwd, clayPort: clayPort, clayTls: clayTls, clayAuthToken: clayAuthToken, slug: slug }).then(function(r) {
1270
+ sm.modelsByVendor[v] = r.models || [];
1271
+ sm.capabilitiesByVendor[v] = r.capabilities || {};
1272
+ if (r.slashCommands) sm.setSlashCommandsForVendor(v, r.slashCommands);
1273
+ }).catch(function(e) {
1274
+ console.error("[sdk-bridge] warmup: " + v + " init failed:", e.message || e);
1275
+ });
1276
+ })(otherVendors[i]);
1277
+ }
1278
+
1279
+ // Detect installed vendors per-user (binary existence check)
1280
+ sm.installedVendors = detectInstalledVendors(linuxUser);
1281
+ sm.availableVendors = Object.keys(adapters);
1282
+
1283
+ // Send initial state to client
1284
+ send({
1285
+ type: "model_info",
1286
+ model: sm.currentModel || "",
1287
+ models: sm.availableModels || [],
1288
+ vendor: defaultVendor,
1289
+ availableVendors: sm.availableVendors,
1290
+ installedVendors: sm.installedVendors,
1291
+ });
1792
1292
  }
1793
1293
 
1794
1294
  async function setModel(session, model) {
1795
- if (session.worker) {
1796
- session.worker.send({ type: "set_model", model: model });
1797
- return;
1798
- }
1799
1295
  if (!session.queryInstance) {
1800
1296
  // No active query — just store the model for next startQuery
1801
1297
  sm.currentModel = model;
1298
+ // Don't send vendor here: session vendor not yet bound, let client keep its selection
1802
1299
  send({ type: "model_info", model: model, models: sm.availableModels || [] });
1803
1300
  send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1804
1301
  return;
@@ -1806,7 +1303,8 @@ function createSDKBridge(opts) {
1806
1303
  try {
1807
1304
  await session.queryInstance.setModel(model);
1808
1305
  sm.currentModel = model;
1809
- send({ type: "model_info", model: model, models: sm.availableModels || [] });
1306
+ var sessionVendor = session.vendor || (adapter && adapter.vendor) || "claude";
1307
+ send({ type: "model_info", model: model, models: sm.availableModels || [], vendor: sessionVendor });
1810
1308
  send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1811
1309
  } catch (e) {
1812
1310
  send({ type: "error", text: "Failed to switch model: " + (e.message || e) });
@@ -1814,26 +1312,20 @@ function createSDKBridge(opts) {
1814
1312
  }
1815
1313
 
1816
1314
  async function setEffort(session, effort) {
1817
- if (session.worker) {
1818
- session.worker.send({ type: "set_effort", effort: effort });
1819
- return;
1820
- }
1821
1315
  if (!session.queryInstance) {
1822
1316
  sm.currentEffort = effort;
1823
1317
  send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
1824
1318
  return;
1825
1319
  }
1826
- // SDK Query interface has no setEffort method.
1827
- // Store the effort level — it will be applied via queryOptions.effort on the next query.
1320
+ // Route through QueryHandle (works for both in-process and worker paths)
1321
+ if (typeof session.queryInstance.setEffort === "function") {
1322
+ await session.queryInstance.setEffort(effort);
1323
+ }
1828
1324
  sm.currentEffort = effort;
1829
1325
  send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
1830
1326
  }
1831
1327
 
1832
1328
  async function setPermissionMode(session, mode) {
1833
- if (session.worker) {
1834
- session.worker.send({ type: "set_permission_mode", mode: mode });
1835
- return;
1836
- }
1837
1329
  if (!session.queryInstance) {
1838
1330
  // No active query — just store the mode for next startQuery
1839
1331
  sm.currentPermissionMode = mode;
@@ -1841,6 +1333,7 @@ function createSDKBridge(opts) {
1841
1333
  return;
1842
1334
  }
1843
1335
  try {
1336
+ // Route through QueryHandle (works for both in-process and worker paths)
1844
1337
  await session.queryInstance.setPermissionMode(mode);
1845
1338
  sm.currentPermissionMode = mode;
1846
1339
  send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
@@ -1853,12 +1346,9 @@ function createSDKBridge(opts) {
1853
1346
  var session = sm.getActiveSession();
1854
1347
  if (!session) return;
1855
1348
  session.taskStopRequested = true;
1856
- if (session.worker) {
1857
- session.worker.send({ type: "stop_task", taskId: taskId });
1858
- return;
1859
- }
1860
1349
  if (!session.queryInstance) return;
1861
1350
  try {
1351
+ // Route through QueryHandle (works for both in-process and worker paths)
1862
1352
  await session.queryInstance.stopTask(taskId);
1863
1353
  } catch (e) {
1864
1354
  console.error("[sdk-bridge] stopTask error:", e.message);
@@ -1874,16 +1364,7 @@ function createSDKBridge(opts) {
1874
1364
  // Creates a mention session that can be reused across multiple mentions
1875
1365
  // within a conversation flow (session continuity).
1876
1366
  async function createMentionSession(opts) {
1877
- // opts: { claudeMd, initialContext, initialMessage, onDelta, onDone, onError, onActivity }
1878
- var sdk;
1879
- try {
1880
- sdk = await getSDK();
1881
- } catch (e) {
1882
- opts.onError("Failed to load Claude SDK: " + (e.message || e));
1883
- return null;
1884
- }
1885
-
1886
- var mq = createMessageQueue();
1367
+ // opts: { vendor, claudeMd, initialContext, initialMessage, onDelta, onDone, onError, onActivity }
1887
1368
  var abortController = new AbortController();
1888
1369
 
1889
1370
  // Current response callbacks (swapped on each pushMessage)
@@ -1896,114 +1377,89 @@ function createSDKBridge(opts) {
1896
1377
  var mentionBlocks = {};
1897
1378
  var alive = true;
1898
1379
 
1899
- var query;
1380
+ // Use the mate's vendor adapter if specified, otherwise default
1381
+ var mentionAdapter = (opts.vendor && adapters[opts.vendor]) || adapter;
1382
+
1383
+ var handle;
1900
1384
  try {
1901
- var mentionQueryOptions = {
1902
- cwd: cwd,
1903
- systemPrompt: opts.claudeMd,
1904
- settingSources: ["user"],
1905
- includePartialMessages: true,
1906
- abortController: abortController,
1907
- canUseTool: opts.canUseTool || function (toolName, input) {
1908
- var whitelisted = checkToolWhitelist(toolName, input);
1909
- if (whitelisted) {
1910
- return Promise.resolve(whitelisted);
1911
- }
1912
- return Promise.resolve({
1913
- behavior: "deny",
1914
- message: "Read-only access. You cannot make changes via @mention.",
1915
- });
1385
+ handle = await mentionAdapter.createQuery({
1386
+ cwd: cwd,
1387
+ systemPrompt: opts.claudeMd,
1388
+ model: opts.model || undefined,
1389
+ toolServers: opts.includeMcpServers ? (mergeMcpServers(mcpServers, getRemoteMcpServers) || undefined) : undefined,
1390
+ abortController: abortController,
1391
+ canUseTool: opts.canUseTool || function (toolName, input) {
1392
+ var whitelisted = checkToolWhitelist(toolName, input);
1393
+ if (whitelisted) {
1394
+ return Promise.resolve(whitelisted);
1395
+ }
1396
+ return Promise.resolve({
1397
+ behavior: "deny",
1398
+ message: "Read-only access. You cannot make changes via @mention.",
1399
+ });
1400
+ },
1401
+ adapterOptions: {
1402
+ CLAUDE: {
1403
+ settingSources: ["user"],
1404
+ includePartialMessages: true,
1916
1405
  },
1917
- };
1918
- if (opts.model) mentionQueryOptions.model = opts.model;
1919
- if (opts.includeMcpServers) {
1920
- var _mentionMcp = mergeMcpServers(mcpServers, getRemoteMcpServers);
1921
- if (_mentionMcp) mentionQueryOptions.mcpServers = _mentionMcp;
1922
- }
1923
- query = sdk.query({
1924
- prompt: mq,
1925
- options: mentionQueryOptions,
1406
+ },
1926
1407
  });
1927
1408
  } catch (e) {
1928
1409
  opts.onError("Failed to create mention query: " + (e.message || e));
1929
1410
  return null;
1930
1411
  }
1412
+ var query = handle;
1931
1413
 
1932
1414
  // Push the initial message (context + question, with optional images)
1933
1415
  var initialPrompt = opts.initialContext + "\n\n" + opts.initialMessage;
1934
- var initialContent = [];
1935
- if (opts.initialImages && opts.initialImages.length > 0) {
1936
- for (var ii = 0; ii < opts.initialImages.length; ii++) {
1937
- initialContent.push({
1938
- type: "image",
1939
- source: { type: "base64", media_type: opts.initialImages[ii].mediaType, data: opts.initialImages[ii].data },
1940
- });
1941
- }
1942
- }
1943
- initialContent.push({ type: "text", text: initialPrompt });
1944
- mq.push({
1945
- type: "user",
1946
- message: { role: "user", content: initialContent },
1947
- });
1416
+ handle.pushMessage(initialPrompt, opts.initialImages || null);
1948
1417
 
1949
- // Background stream processing loop
1418
+ // Background stream processing loop (consumes flattened yokeType events)
1950
1419
  (async function () {
1951
1420
  try {
1952
- for await (var sdkMsg of query) {
1953
- if (sdkMsg.type === "stream_event" && sdkMsg.event) {
1954
- var evt = sdkMsg.event;
1955
-
1956
- // Track content blocks for activity reporting
1957
- if (evt.type === "content_block_start") {
1958
- var block = evt.content_block;
1959
- var idx = evt.index;
1960
- if (block.type === "thinking") {
1961
- mentionBlocks[idx] = { type: "thinking" };
1962
- if (currentOnActivity) currentOnActivity("thinking");
1963
- } else if (block.type === "tool_use") {
1964
- mentionBlocks[idx] = { type: "tool_use", name: block.name, inputJson: "" };
1965
- var toolLabel = block.name;
1966
- if (toolLabel === "Read") toolLabel = "Reading file...";
1967
- else if (toolLabel === "Grep") toolLabel = "Searching code...";
1968
- else if (toolLabel === "Glob") toolLabel = "Finding files...";
1969
- if (currentOnActivity) currentOnActivity(toolLabel);
1970
- } else if (block.type === "text") {
1971
- mentionBlocks[idx] = { type: "text" };
1972
- }
1973
- }
1974
-
1975
- if (evt.type === "content_block_delta" && evt.delta) {
1976
- if (evt.delta.type === "text_delta" && typeof evt.delta.text === "string") {
1977
- responseStreamedText = true;
1978
- responseFullText += evt.delta.text;
1979
- if (currentOnActivity) currentOnActivity(null); // clear activity on text
1980
- if (currentOnDelta) currentOnDelta(evt.delta.text);
1981
- } else if (evt.delta.type === "input_json_delta" && mentionBlocks[evt.index]) {
1982
- mentionBlocks[evt.index].inputJson += evt.delta.partial_json;
1983
- }
1984
- }
1985
-
1986
- if (evt.type === "content_block_stop") {
1987
- var blk = mentionBlocks[evt.index];
1988
- if (blk && blk.type === "tool_use") {
1989
- // Show what file is being read
1990
- var toolInput = {};
1991
- try { toolInput = JSON.parse(blk.inputJson); } catch (e) {}
1992
- if (blk.name === "Read" && toolInput.file_path) {
1993
- var fname = toolInput.file_path.split(/[/\\]/).pop();
1994
- if (currentOnActivity) currentOnActivity("Reading " + fname + "...");
1995
- } else if (blk.name === "Grep" && toolInput.pattern) {
1996
- if (currentOnActivity) currentOnActivity("Searching: " + toolInput.pattern.substring(0, 30) + "...");
1997
- } else if (blk.name === "Glob" && toolInput.pattern) {
1998
- if (currentOnActivity) currentOnActivity("Finding: " + toolInput.pattern.substring(0, 30) + "...");
1999
- }
1421
+ for await (var msg of query) {
1422
+ // Track content blocks for activity reporting
1423
+ if (msg.yokeType === "thinking_start") {
1424
+ mentionBlocks[msg.blockId] = { type: "thinking" };
1425
+ if (currentOnActivity) currentOnActivity("thinking");
1426
+ } else if (msg.yokeType === "tool_start") {
1427
+ mentionBlocks[msg.blockId] = { type: "tool_use", name: msg.toolName, inputJson: "" };
1428
+ var toolLabel = msg.toolName;
1429
+ if (toolLabel === "Read") toolLabel = "Reading file...";
1430
+ else if (toolLabel === "Grep") toolLabel = "Searching code...";
1431
+ else if (toolLabel === "Glob") toolLabel = "Finding files...";
1432
+ if (currentOnActivity) currentOnActivity(toolLabel);
1433
+ } else if (msg.yokeType === "text_start") {
1434
+ mentionBlocks[msg.blockId] = { type: "text" };
1435
+
1436
+ } else if (msg.yokeType === "text_delta" && typeof msg.text === "string") {
1437
+ responseStreamedText = true;
1438
+ responseFullText += msg.text;
1439
+ if (currentOnActivity) currentOnActivity(null);
1440
+ if (currentOnDelta) currentOnDelta(msg.text);
1441
+ } else if (msg.yokeType === "tool_input_delta" && mentionBlocks[msg.blockId]) {
1442
+ mentionBlocks[msg.blockId].inputJson += msg.partialJson;
1443
+
1444
+ } else if (msg.yokeType === "block_stop") {
1445
+ var blk = mentionBlocks[msg.blockId];
1446
+ if (blk && blk.type === "tool_use") {
1447
+ var toolInput = {};
1448
+ try { toolInput = JSON.parse(blk.inputJson); } catch (e) {}
1449
+ if (blk.name === "Read" && toolInput.file_path) {
1450
+ var fname = toolInput.file_path.split(/[/\\]/).pop();
1451
+ if (currentOnActivity) currentOnActivity("Reading " + fname + "...");
1452
+ } else if (blk.name === "Grep" && toolInput.pattern) {
1453
+ if (currentOnActivity) currentOnActivity("Searching: " + toolInput.pattern.substring(0, 30) + "...");
1454
+ } else if (blk.name === "Glob" && toolInput.pattern) {
1455
+ if (currentOnActivity) currentOnActivity("Finding: " + toolInput.pattern.substring(0, 30) + "...");
2000
1456
  }
2001
- delete mentionBlocks[evt.index];
2002
1457
  }
1458
+ delete mentionBlocks[msg.blockId];
2003
1459
 
2004
- } else if (sdkMsg.type === "assistant" && !responseStreamedText && sdkMsg.message && sdkMsg.message.content) {
1460
+ } else if (msg.yokeType === "message" && msg.messageRole === "assistant" && !responseStreamedText && msg.content) {
2005
1461
  // Fallback: if text was not streamed via deltas, extract from assistant message
2006
- var content = sdkMsg.message.content;
1462
+ var content = msg.content;
2007
1463
  if (Array.isArray(content)) {
2008
1464
  for (var ci = 0; ci < content.length; ci++) {
2009
1465
  if (content[ci].type === "text" && content[ci].text) {
@@ -2012,7 +1468,8 @@ function createSDKBridge(opts) {
2012
1468
  }
2013
1469
  }
2014
1470
  }
2015
- } else if (sdkMsg.type === "result") {
1471
+
1472
+ } else if (msg.yokeType === "result") {
2016
1473
  // One response complete. Signal done and reset for next message.
2017
1474
  if (currentOnActivity) currentOnActivity(null);
2018
1475
  var doneRef = currentOnDone;
@@ -2054,27 +1511,14 @@ function createSDKBridge(opts) {
2054
1511
  mentionBlocks = {};
2055
1512
  responseFullText = "";
2056
1513
  responseStreamedText = false;
2057
- var content = [];
2058
- if (images && images.length > 0) {
2059
- for (var pi = 0; pi < images.length; pi++) {
2060
- content.push({
2061
- type: "image",
2062
- source: { type: "base64", media_type: images[pi].mediaType, data: images[pi].data },
2063
- });
2064
- }
2065
- }
2066
- content.push({ type: "text", text: text });
2067
- mq.push({
2068
- type: "user",
2069
- message: { role: "user", content: content },
2070
- });
1514
+ handle.pushMessage(text, images || null);
2071
1515
  },
2072
1516
  abort: function () {
2073
1517
  try { abortController.abort(); } catch (e) {}
2074
1518
  },
2075
1519
  close: function () {
2076
1520
  alive = false;
2077
- try { mq.end(); } catch (e) {}
1521
+ try { handle.close(); } catch (e) {}
2078
1522
  },
2079
1523
  isAlive: function () { return alive; },
2080
1524
  };
@@ -2088,6 +1532,10 @@ function createSDKBridge(opts) {
2088
1532
  handleElicitation: handleElicitation,
2089
1533
  processQueryStream: processQueryStream,
2090
1534
  getOrCreateRewindQuery: getOrCreateRewindQuery,
1535
+ rewindPreview: rewindPreview,
1536
+ rewindExecuteFiles: rewindExecuteFiles,
1537
+ rollbackConversation: rollbackConversation,
1538
+ forkSession: forkSessionUnified,
2091
1539
  startQuery: startQuery,
2092
1540
  pushMessage: pushMessage,
2093
1541
  setModel: setModel,