clay-server 4.7.0-beta.5 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -150,6 +150,7 @@ function rebuildMessageUUIDs(history) {
150
150
  uuid: history[i].uuid,
151
151
  type: history[i].messageType,
152
152
  historyIndex: i,
153
+ turnId: history[i].turnId || null,
153
154
  });
154
155
  }
155
156
  }
@@ -1489,14 +1489,26 @@ function attachSessions(ctx) {
1489
1489
  }
1490
1490
  }
1491
1491
 
1492
- // Count turns to roll back BEFORE trimming local history
1493
- var turnsToRollBack = 0;
1492
+ // Codex's thread/revert cuts at a turn id, not a count. Read the
1493
+ // target entry's turnId BEFORE trimming local history below.
1494
+ var hasTurnsToRollBack = false;
1495
+ var targetTurnId = null;
1494
1496
  if (targetIdx >= 0) {
1497
+ targetTurnId = session.messageUUIDs[targetIdx].turnId || null;
1495
1498
  for (var ri = targetIdx; ri < session.messageUUIDs.length; ri++) {
1496
- if (session.messageUUIDs[ri].type === "user") turnsToRollBack++;
1499
+ if (session.messageUUIDs[ri].type === "user") { hasTurnsToRollBack = true; break; }
1497
1500
  }
1498
1501
  }
1499
1502
 
1503
+ // Notify adapter of conversation rollback (e.g. Codex thread/revert)
1504
+ // BEFORE trimming local history below. Deliberately not caught
1505
+ // here: a failed or impossible (missing turnId) provider-side
1506
+ // rollback must abort the whole rewind via the outer catch, never
1507
+ // leave local history trimmed ahead of the real provider thread.
1508
+ if (hasTurnsToRollBack) {
1509
+ await sdk.rollbackConversation(session, targetTurnId);
1510
+ }
1511
+
1500
1512
  if (targetIdx >= 0) {
1501
1513
  var trimTo = session.messageUUIDs[targetIdx].historyIndex;
1502
1514
  for (var k = trimTo - 1; k >= 0; k--) {
@@ -1513,15 +1525,6 @@ function attachSessions(ctx) {
1513
1525
  }
1514
1526
  }
1515
1527
 
1516
- // Notify adapter of conversation rollback (e.g. Codex thread/rollback)
1517
- if (turnsToRollBack > 0) {
1518
- try {
1519
- await sdk.rollbackConversation(session, turnsToRollBack);
1520
- } catch (rbErr) {
1521
- console.error("[project-sessions] conversation rollback failed:", rbErr.message || rbErr);
1522
- }
1523
- }
1524
-
1525
1528
  var kept = session.messageUUIDs;
1526
1529
  session.lastRewindUuid = kept.length > 0 ? kept[kept.length - 1].uuid : null;
1527
1530
  }
@@ -1588,7 +1591,7 @@ function attachSessions(ctx) {
1588
1591
  forked.messageUUIDs = [];
1589
1592
  for (var hi = 0; hi < forkHistory.length; hi++) {
1590
1593
  if (forkHistory[hi].type === "message_uuid") {
1591
- forked.messageUUIDs.push({ uuid: forkHistory[hi].uuid, type: forkHistory[hi].messageType, historyIndex: hi });
1594
+ forked.messageUUIDs.push({ uuid: forkHistory[hi].uuid, type: forkHistory[hi].messageType, historyIndex: hi, turnId: forkHistory[hi].turnId || null });
1592
1595
  }
1593
1596
  }
1594
1597
  sm.saveSessionFile(forked);
@@ -1718,21 +1718,17 @@ body.tui-composer-active #config-chip-wrap {
1718
1718
  color: inherit;
1719
1719
  }
1720
1720
 
1721
- /* Stop remains available when Send is occupied by a draft or Worker lock. */
1722
- #generation-stop-row {
1723
- max-width: var(--content-width);
1724
- margin: 6px auto 0;
1725
- display: flex;
1726
- justify-content: flex-end;
1727
- }
1728
- #generation-stop-row.hidden { display: none; }
1729
- body:not(.worker-pane-locked) #input-area:has(#send-btn.stop:not(:disabled)) #generation-stop-row { display: none; }
1721
+ /* Stop remains available when Send is occupied by a draft or a locked
1722
+ configured Worker pane. One button, two homes, moved (never duplicated) by
1723
+ generation-stop.js: beside Send in the ordinary composer action row, or a
1724
+ direct child of #input-area -- outside the composer that worker-pane-lock.js
1725
+ hides with #input-wrapper { display: none } -- when the pane is locked. */
1730
1726
  #generation-stop {
1731
1727
  display: inline-flex;
1732
1728
  align-items: center;
1733
1729
  gap: 6px;
1734
- min-height: 36px;
1735
- padding: 6px 12px;
1730
+ height: 36px;
1731
+ padding: 0 12px;
1736
1732
  border: 1px solid var(--border);
1737
1733
  border-radius: 18px;
1738
1734
  background: var(--sidebar-bg);
@@ -1740,7 +1736,19 @@ body:not(.worker-pane-locked) #input-area:has(#send-btn.stop:not(:disabled)) #ge
1740
1736
  font: inherit;
1741
1737
  font-size: 12px;
1742
1738
  cursor: pointer;
1739
+ flex-shrink: 0;
1743
1740
  }
1741
+ #generation-stop.hidden { display: none; }
1742
+ body:not(.worker-pane-locked) #input-bottom-right:has(#send-btn.stop:not(:disabled)) #generation-stop { display: none; }
1744
1743
  #generation-stop:hover:not(:disabled) { color: var(--text); background: var(--code-bg); }
1745
1744
  #generation-stop:disabled { opacity: 0.5; cursor: default; }
1746
1745
  #generation-stop .lucide { width: 12px; height: 12px; }
1746
+
1747
+ /* Locked placement: a direct child of #input-area, centered under the
1748
+ "Controlled by Driver" status line rather than inline in a hidden row. */
1749
+ #input-area > #generation-stop {
1750
+ display: flex;
1751
+ width: fit-content;
1752
+ margin: 8px auto 0;
1753
+ }
1754
+ #input-area > #generation-stop.hidden { display: none; }
@@ -630,14 +630,12 @@
630
630
  </div>
631
631
  </div>
632
632
  <button id="stt-btn" type="button" aria-label="Voice input" title="Voice input"><i data-lucide="mic"></i></button>
633
+ <button id="generation-stop" type="button" class="hidden" aria-label="Stop generating"><i data-lucide="square"></i><span>Stop</span></button>
633
634
  <button id="send-btn" disabled aria-label="Send"><i data-lucide="arrow-up"></i></button>
634
635
  </div>
635
636
  </div>
636
637
  </div>
637
638
  </div>
638
- <div id="generation-stop-row" class="hidden">
639
- <button id="generation-stop" type="button" aria-label="Stop generating"><i data-lucide="square"></i><span>Stop</span></button>
640
- </div>
641
639
  </div>
642
640
  </div>
643
641
  <!-- Mobile input-more bottom sheet: attach / image + context sources -->
@@ -35,6 +35,7 @@ import { renderWorkerProposal, updateWorkerProposal } from './worker-proposal.js
35
35
  import { renderDriverContinuation, updateDriverContinuation, renderDriverContinuationContext } from './driver-continuation.js';
36
36
  import { handleInputSync, autoResize, builtinCommands, setScheduleBtnDisabled, sendShellResultToAgent, handleAcceptedOrdinaryMessage, handleScheduleMessageResult } from './input.js';
37
37
  import { startThinking, appendThinking, stopThinking, resetThinkingGroup, createToolItem, updateToolExecuting, updateToolResult, markAllToolsDone, closeToolGroup, removeToolFromGroup, resetToolState, getTools, getPlanContent, setPlanContent, renderPlanBanner, renderPlanCard, getTodoTools, handleTodoWrite, handleTaskCreate, handleTaskUpdate, applyDeadSessionTodoCompaction, isPlanFilePath, enableMainInput, addTurnMeta, updateSubagentActivity, addSubagentToolEntry, markSubagentDone, initSubagentStop, updateSubagentProgress, updateSubagentTaskStatus, renderAskUserQuestion, markAskUserAnswered, renderPermissionRequest, markPermissionCancelled, markPermissionResolved, renderElicitationRequest, markElicitationResolved, renderUserDialogRequest, markUserDialogResolved, updateThinkingTokens } from './tools.js';
38
+ import { isInterimToolResult } from './tool-result-lifecycle.js';
38
39
  import { finishThinkingTurn, resumeThinkingAfterReplay } from './thinking-lifecycle.js';
39
40
  import { showDoneNotification, playDoneSound, isNotifAlertEnabled, isNotifSoundEnabled } from './notifications.js';
40
41
  import { handleFsList, handleFsRead, handleFileChanged, handleDirChanged, handleFileHistory, handleGitDiff, handleFileAt, refreshIfOpen, getPendingNavigate, handleFsSearch, presentMarkdownEdit } from './filebrowser.js';
@@ -1194,6 +1195,12 @@ export function processMessage(msg) {
1194
1195
  case "tool_result": {
1195
1196
  var tr = getTools()[msg.id];
1196
1197
  if (tr && tr.hidden) break; // skip hidden plan tools
1198
+ // A detached SDK placeholder means this exact tool call is still
1199
+ // running. Leave its card and label untouched; history replay and
1200
+ // split panes use this same router, so a later real result can
1201
+ // complete the original card everywhere without a false
1202
+ // "(no output)" terminal state.
1203
+ if (isInterimToolResult(msg)) break;
1197
1204
  if (msg.is_error && tr && tr.name === "ImageGen") clearImageGenerationProgress(msg.id);
1198
1205
  // Always call updateToolResult for Edit (to show diff from input), or when content exists
1199
1206
  if (msg.content != null || msg.images || (tr && tr.name === "Edit" && tr.input && tr.input.old_string)) {
@@ -1,16 +1,35 @@
1
1
  import { store } from './store.js';
2
2
  import { getWs } from './ws-ref.js';
3
+ import { isDriverOperatedView } from './worker-pane-lock.js';
3
4
 
4
- // This control lives outside the composer so drafts and Worker locks cannot
5
- // take away the human's ability to stop the displayed session.
5
+ // Fallback Stop control for when Send cannot double as Stop: a draft sitting
6
+ // in the composer (Send stays Send so the draft can still go out), or a
7
+ // locked configured Worker pane (Send and the rest of the composer are
8
+ // hidden entirely). One button, two homes, moved rather than duplicated:
9
+ // beside Send in the ordinary composer action row, or a direct child of
10
+ // #input-area -- outside the composer that worker-pane-lock.js hides with
11
+ // #input-wrapper { display: none } -- when the pane is locked. Either way
12
+ // there is never more than one visible Stop, and a locked pane's hidden
13
+ // composer never takes the human's cancellation away with it.
6
14
  export function initGenerationStop() {
7
- var row = document.getElementById('generation-stop-row');
8
15
  var button = document.getElementById('generation-stop');
9
- if (!row || !button) return;
16
+ if (!button) return;
17
+ var inputArea = document.getElementById('input-area');
18
+ var actionRow = document.getElementById('input-bottom-right');
19
+ var sendBtn = document.getElementById('send-btn');
20
+
21
+ function place() {
22
+ if (isDriverOperatedView()) {
23
+ if (inputArea && button.parentNode !== inputArea) inputArea.appendChild(button);
24
+ } else if (actionRow && button.parentNode !== actionRow) {
25
+ actionRow.insertBefore(button, sendBtn || null);
26
+ }
27
+ }
10
28
  function sync() {
11
- row.classList.toggle('hidden', !store.get('processing'));
29
+ button.classList.toggle('hidden', !store.get('processing'));
12
30
  button.disabled = !store.get('connected');
13
31
  button.title = store.get('connected') ? 'Stop generating' : 'Reconnect to stop generating';
32
+ place();
14
33
  }
15
34
  button.addEventListener('click', function () {
16
35
  var ws = getWs();
@@ -18,7 +37,9 @@ export function initGenerationStop() {
18
37
  ws.send(JSON.stringify({ type: 'stop' }));
19
38
  });
20
39
  store.subscribe(function (state, previous) {
21
- if (state.processing !== previous.processing || state.connected !== previous.connected) sync();
40
+ if (state.processing !== previous.processing || state.connected !== previous.connected
41
+ || state.splitGroups !== previous.splitGroups || state.activeSessionId !== previous.activeSessionId
42
+ || state.paneSessionId !== previous.paneSessionId) sync();
22
43
  });
23
44
  sync();
24
45
  }
@@ -0,0 +1,3 @@
1
+ export function isInterimToolResult(msg) {
2
+ return !!msg && msg.detached === true;
3
+ }
@@ -13,7 +13,7 @@
13
13
  @import url("css/messages.css?v=20260828-delegated-muted1");
14
14
  @import url("css/generated-images.css?v=20260829-save-details1");
15
15
  @import url("css/rewind.css");
16
- @import url("css/input.css?v=20260828-composer-depth1");
16
+ @import url("css/input.css?v=20261002-stop-inline1");
17
17
  @import url("css/pending-message-queue.css?v=20260914-queue1");
18
18
  @import url("css/filebrowser.css?v=20260829-terminal-canvas1");
19
19
  @import url("css/git-panel.css");
package/lib/sdk-bridge.js CHANGED
@@ -12,6 +12,7 @@ var skillDiscovery = require("./yoke/skill-discovery");
12
12
  var { isSafeBashSegment } = require("./safe-bash-commands");
13
13
  var { createMessageQueue } = require("./sdk-message-queue");
14
14
  var { attachMessageProcessor } = require("./sdk-message-processor");
15
+ var taskLifecycle = require("./sdk-task-lifecycle");
15
16
  var homeDebateToolPolicy = require("./home-debate-tool-policy");
16
17
  var yoke = require("./yoke");
17
18
  var sessionTitlePolicy = require("./session-title-policy");
@@ -851,6 +852,14 @@ function createSDKBridge(opts) {
851
852
  }
852
853
 
853
854
  // Regular tool permission request: send to client and wait
855
+ // mcpServer (name/source) is SDK-reported metadata about which MCP
856
+ // server actually served the tool call. It comes from the trusted CLI
857
+ // subprocess boundary in opts, never from the model-controlled `input`,
858
+ // so it is safe to surface informationally; it is not used to grant or
859
+ // widen any permission decision made above.
860
+ var mcpServerMeta = (opts.mcpServer && typeof opts.mcpServer === "object")
861
+ ? { name: opts.mcpServer.name || null, source: opts.mcpServer.source || null }
862
+ : null;
854
863
  return new Promise(function(resolve) {
855
864
  var requestId = crypto.randomUUID();
856
865
  sm.permissionRequestIndex[requestId] = session.localId;
@@ -863,6 +872,7 @@ function createSDKBridge(opts) {
863
872
  decisionReason: opts.decisionReason || "",
864
873
  defaultToNo: opts.defaultToNo === true,
865
874
  suppressAlwaysAllowRule: opts.suppressAlwaysAllowRule === true,
875
+ mcpServer: mcpServerMeta,
866
876
  };
867
877
 
868
878
  var permMsg = {
@@ -874,6 +884,7 @@ function createSDKBridge(opts) {
874
884
  decisionReason: opts.decisionReason || "",
875
885
  defaultToNo: opts.defaultToNo === true,
876
886
  suppressAlwaysAllowRule: opts.suppressAlwaysAllowRule === true,
887
+ mcpServer: mcpServerMeta,
877
888
  vendor: session.vendor || (adapter && adapter.vendor) || "claude",
878
889
  };
879
890
  sendAndRecord(session, permMsg);
@@ -1103,6 +1114,7 @@ function createSDKBridge(opts) {
1103
1114
  session.isProcessing = false;
1104
1115
  session._awaitingTurnResult = false;
1105
1116
  session._queuedTurnCount = 0;
1117
+ taskLifecycle.clearForeground(session);
1106
1118
  onProcessingChanged();
1107
1119
  send({ type: "status", processing: false });
1108
1120
  sendAndRecord(session, { type: "thinking_stop" });
@@ -1119,6 +1131,7 @@ function createSDKBridge(opts) {
1119
1131
  session.isProcessing = false;
1120
1132
  session._awaitingTurnResult = false;
1121
1133
  session._queuedTurnCount = 0;
1134
+ taskLifecycle.clearAll(session);
1122
1135
  onProcessingChanged();
1123
1136
  if (!session._lastAdapterError) {
1124
1137
  sendAndRecord(session, {
@@ -1138,6 +1151,7 @@ function createSDKBridge(opts) {
1138
1151
  session._queuedTurnCount = 0;
1139
1152
  onProcessingChanged();
1140
1153
  if (err.name === "AbortError" || (myAbortController && myAbortController.signal.aborted) || session.taskStopRequested) {
1154
+ taskLifecycle.clearForeground(session);
1141
1155
  session._lastTurnInterrupted = true;
1142
1156
  if (!session.destroying) {
1143
1157
  sendAndRecord(session, { type: "thinking_stop" });
@@ -1148,9 +1162,11 @@ function createSDKBridge(opts) {
1148
1162
  sendAndRecord(session, { type: "done", code: 0 });
1149
1163
  }
1150
1164
  } else if (session.destroying) {
1165
+ taskLifecycle.clearAll(session);
1151
1166
  // Suppress error messages during shutdown
1152
1167
  console.log("[sdk-bridge] Suppressing stream error during shutdown for session " + session.localId);
1153
1168
  } else {
1169
+ taskLifecycle.clearAll(session);
1154
1170
  var errDetail = err.message || String(err);
1155
1171
  if (err.stderr) errDetail += "\nstderr: " + err.stderr;
1156
1172
  if (err.exitCode != null) errDetail += " (exitCode: " + err.exitCode + ")";
@@ -1370,10 +1386,19 @@ function createSDKBridge(opts) {
1370
1386
  }
1371
1387
  }
1372
1388
 
1373
- async function rollbackConversation(session, numTurns) {
1389
+ async function rollbackConversation(session, turnId) {
1374
1390
  var sessionAdapter = getAdapterForSession(session);
1375
1391
  if (sessionAdapter && typeof sessionAdapter.rollbackThread === "function") {
1376
- await sessionAdapter.rollbackThread(session.cliSessionId, numTurns, {
1392
+ // Codex's thread/revert requires its own turnId (beforeTurnId), not a
1393
+ // turn count. A session whose target turn predates turnId capture (or
1394
+ // whose persisted record lost it) has nothing valid to send: throwing
1395
+ // here -- rather than guessing or silently skipping -- lets the caller
1396
+ // abort the whole rewind instead of leaving local history trimmed
1397
+ // ahead of the real, un-rolled-back provider thread.
1398
+ if (!turnId) {
1399
+ throw new Error("No turn id recorded for this turn; cannot roll back the Codex thread.");
1400
+ }
1401
+ await sessionAdapter.rollbackThread(session.cliSessionId, turnId, {
1377
1402
  linuxUser: getSessionLinuxUser(session) || undefined,
1378
1403
  });
1379
1404
  }
@@ -1620,7 +1645,7 @@ function createSDKBridge(opts) {
1620
1645
 
1621
1646
  session.blocks = {};
1622
1647
  session.sentToolResults = {};
1623
- session.activeTaskToolIds = {};
1648
+ taskLifecycle.clearAll(session);
1624
1649
  session.pendingElicitations = {};
1625
1650
  session.streamedText = false;
1626
1651
  session.responsePreview = "";
@@ -1685,9 +1710,16 @@ function createSDKBridge(opts) {
1685
1710
  else if (session.acceptEditsAfterStart) effectiveDefault = "acceptEdits";
1686
1711
  else effectiveDefault = globalMode;
1687
1712
  var modeToApply = session._loopPermissionMode || effectiveDefault;
1688
- if (modeToApply && modeToApply !== "default") {
1689
- claudeOpts.permissionMode = modeToApply;
1690
- }
1713
+ // Always set permissionMode explicitly, including "default". SDK
1714
+ // 0.3.286+ leaves an omitted permissionMode to Claude Code itself,
1715
+ // which can apply a settings defaultMode or start in auto mode on
1716
+ // third-party providers / with telemetry off -- silently widening
1717
+ // past Clay's Ask policy. Sending "default" keeps the SDK's own
1718
+ // decision point at the same "default" behavior Clay has always run
1719
+ // under: a tool the SDK's own allowed/denied rules or allowedTools
1720
+ // list already settles is not re-asked, but anything left undecided
1721
+ // still reaches Clay's canUseTool callback, as before.
1722
+ claudeOpts.permissionMode = modeToApply || "default";
1691
1723
  }
1692
1724
  // Clear one-shot acceptEditsAfterStart regardless of which branch ran above,
1693
1725
  // so the flag does not linger into subsequent turns.
@@ -2022,6 +2054,7 @@ function createSDKBridge(opts) {
2022
2054
  session.isProcessing = false;
2023
2055
  session._awaitingTurnResult = false;
2024
2056
  session._queuedTurnCount = 0;
2057
+ taskLifecycle.clearAll(session);
2025
2058
  onProcessingChanged();
2026
2059
  session.queryInstance = null;
2027
2060
  session.messageQueue = null;
@@ -2079,6 +2112,7 @@ function createSDKBridge(opts) {
2079
2112
  session.isProcessing = false;
2080
2113
  session._awaitingTurnResult = false;
2081
2114
  session._queuedTurnCount = 0;
2115
+ taskLifecycle.clearAll(session);
2082
2116
  onProcessingChanged();
2083
2117
  sm.broadcastSessionList();
2084
2118
  return false;
@@ -2096,6 +2130,7 @@ function createSDKBridge(opts) {
2096
2130
  session.isProcessing = false;
2097
2131
  session._awaitingTurnResult = false;
2098
2132
  session._queuedTurnCount = 0;
2133
+ taskLifecycle.clearAll(session);
2099
2134
  onProcessingChanged();
2100
2135
  sendAndRecord(session, { type: "error", text: "The agent connection closed before it received your message. Please send it again." });
2101
2136
  sendAndRecord(session, { type: "done", code: 1 });
@@ -2499,6 +2534,7 @@ function createSDKBridge(opts) {
2499
2534
  var session = sm.getActiveSession();
2500
2535
  if (!session) return;
2501
2536
  session.taskStopRequested = true;
2537
+ taskLifecycle.finish(session, null, taskId);
2502
2538
  if (!session.queryInstance) return;
2503
2539
  try {
2504
2540
  // Route through QueryHandle (works for both in-process and worker paths)
@@ -2582,6 +2618,10 @@ function createSDKBridge(opts) {
2582
2618
  CLAUDE: {
2583
2619
  settingSources: ["user"],
2584
2620
  includePartialMessages: true,
2621
+ // Explicit, not omitted: SDK 0.3.286+ leaves an omitted
2622
+ // permissionMode to Claude Code's own default/auto behavior,
2623
+ // which could bypass the deny-by-default canUseTool above.
2624
+ permissionMode: "default",
2585
2625
  },
2586
2626
  },
2587
2627
  });
@@ -1,5 +1,6 @@
1
1
  var usersModule = require("./users");
2
2
  var turnQueue = require("./sdk-turn-queue");
3
+ var taskLifecycle = require("./sdk-task-lifecycle");
3
4
  var contextUsage = require("./context-usage");
4
5
  var yoke = require("./yoke");
5
6
  var backgroundTaskTiming = require("./background-task-timing");
@@ -193,8 +194,11 @@ function attachMessageProcessor(ctx) {
193
194
  // Capture message UUIDs for rewind support
194
195
  if (parsed.uuid) {
195
196
  if (parsed.messageType === "user" && !parsed.parentToolUseId) {
196
- session.messageUUIDs.push({ uuid: parsed.uuid, type: "user", historyIndex: session.history.length });
197
- sendAndRecord(session, { type: "message_uuid", uuid: parsed.uuid, messageType: "user" });
197
+ // turnId (Codex's own turn identifier, distinct from this uuid) is
198
+ // stored so a later rewind can send Codex's thread/revert
199
+ // beforeTurnId instead of a turn count. Undefined for Claude.
200
+ session.messageUUIDs.push({ uuid: parsed.uuid, type: "user", historyIndex: session.history.length, turnId: parsed.turnId || null });
201
+ sendAndRecord(session, { type: "message_uuid", uuid: parsed.uuid, messageType: "user", turnId: parsed.turnId || null });
198
202
  } else if (parsed.messageType === "assistant") {
199
203
  session.messageUUIDs.push({ uuid: parsed.uuid, type: "assistant", historyIndex: session.history.length });
200
204
  sendAndRecord(session, { type: "message_uuid", uuid: parsed.uuid, messageType: "assistant" });
@@ -252,6 +256,14 @@ function attachMessageProcessor(ctx) {
252
256
  if (parsed.fastModeState) {
253
257
  sendAndRecord(session, { type: "fast_mode_state", state: parsed.fastModeState });
254
258
  }
259
+ if (Array.isArray(parsed.pluginErrors) && parsed.pluginErrors.length > 0) {
260
+ var pluginPaths = parsed.pluginErrors.map(function (e) { return e && e.path; }).filter(Boolean);
261
+ sendAndRecord(session, {
262
+ type: "informational",
263
+ level: "warning",
264
+ content: "Plugin failed to load: " + (pluginPaths.join(", ") || "unknown path"),
265
+ });
266
+ }
255
267
  }
256
268
 
257
269
  if (parsed.yokeType === "turn_start") {
@@ -474,13 +486,29 @@ function attachMessageProcessor(ctx) {
474
486
  for (var i = 0; i < content.length; i++) {
475
487
  var block = content[i];
476
488
  if (block.type === "tool_result" && !session.sentToolResults[block.tool_use_id]) {
477
- // Clear active Task tool when its result arrives
478
- if (session.activeTaskToolIds && session.activeTaskToolIds[block.tool_use_id]) {
489
+ // SDK: a WebFetch/WebSearch call that stepped aside for a
490
+ // priority message carries { detachedToolCall: true } here "in
491
+ // place of its Output: the call is still running, and its result
492
+ // reaches the model in a later turn" under the same tool_use_id.
493
+ // Do not mark it sent, or that later real tool_result for the
494
+ // same id would be silently dropped by the dedup check above;
495
+ // do not clear its active-task bookkeeping either, since it has
496
+ // not actually finished.
497
+ var isDetached = !!(parsed.toolUseResult
498
+ && parsed.toolUseResult.detachedToolCall === true
499
+ && parsed.detachedToolId === block.tool_use_id);
500
+
501
+ // Clear both lifecycle indexes when the real terminal result
502
+ // arrives. A later duplicate task_notification will then be
503
+ // recognized as already settled.
504
+ var finishedTask = isDetached
505
+ ? { matched: false }
506
+ : taskLifecycle.finish(session, block.tool_use_id, null);
507
+ if (finishedTask.matched) {
479
508
  sendAndRecord(session, {
480
509
  type: "subagent_done",
481
510
  parentToolId: block.tool_use_id,
482
511
  });
483
- delete session.activeTaskToolIds[block.tool_use_id];
484
512
  }
485
513
  var resultText = "";
486
514
  var resultImages = [];
@@ -501,13 +529,14 @@ function attachMessageProcessor(ctx) {
501
529
  }
502
530
  }
503
531
  }
504
- session.sentToolResults[block.tool_use_id] = true;
532
+ if (!isDetached) session.sentToolResults[block.tool_use_id] = true;
505
533
  var toolResultMsg = {
506
534
  type: "tool_result",
507
535
  id: block.tool_use_id,
508
536
  content: resultText,
509
537
  is_error: block.is_error || false,
510
538
  };
539
+ if (isDetached) toolResultMsg.detached = true;
511
540
  if (resultImages.length > 0) toolResultMsg.images = resultImages;
512
541
  sendAndRecord(session, toolResultMsg);
513
542
  }
@@ -515,6 +544,22 @@ function attachMessageProcessor(ctx) {
515
544
  }
516
545
 
517
546
  } else if (parsed.yokeType === "result") {
547
+ // SDK 0.3.274 batches queued background-task completion wakes into one
548
+ // model call. Every wake still emits a result, but all results before
549
+ // the final wake are successful task-notification-origin placeholders
550
+ // with no model turns and no result text. They are not user-turn
551
+ // completion boundaries: queued_turn_count cannot identify them because
552
+ // that field counts user sends only. Keep permissions, elicitations,
553
+ // processing state, completion callbacks and notifications intact until
554
+ // the real result arrives.
555
+ var isIntermediateBackgroundResult = parsed.subtype === "success"
556
+ && parsed.is_error !== true
557
+ && parsed.numTurns === 0
558
+ && parsed.origin && parsed.origin.kind === "task-notification"
559
+ && !parsed.localCommand
560
+ && parsed.resultText === "";
561
+ if (isIntermediateBackgroundResult) return;
562
+
518
563
  var overloadAbortRequested = !!session._overloadAbortRequested;
519
564
  session._overloadAbortRequested = false;
520
565
  session.blocks = {};
@@ -539,8 +584,16 @@ function attachMessageProcessor(ctx) {
539
584
  sendAndRecord(session, { type: "ask_user_answered", toolId: lid });
540
585
  }
541
586
  session.pendingAskUser = keptAskUser;
542
- session.activeTaskToolIds = {};
543
- session.taskIdMap = {};
587
+ // activeTaskToolIds / taskIdMap are deliberately NOT reset here. A
588
+ // backgrounded Task/agent/MCP call (SDK 0.3.286: a priority "now"
589
+ // message can move a running tool to the background and let the turn's
590
+ // own result fire without waiting for it) can still be genuinely
591
+ // running when this result arrives. Clearing these maps here would
592
+ // orphan that task:
593
+ // its real tool_result / task_notification would arrive later with no
594
+ // matching entry, and the client would never get subagent_done. Both
595
+ // maps are self-cleaning at the point each tracked task actually
596
+ // finishes (see the tool_result and task_notification handlers below).
544
597
  // Only clear rateLimitResetsAt on genuine success (non-zero cost).
545
598
  // When rate-limited, the SDK sends result with zero cost right after
546
599
  // rate_limit_event; clearing here would prevent auto-continue scheduling.
@@ -549,6 +602,23 @@ function attachMessageProcessor(ctx) {
549
602
  }
550
603
  console.log("[sdk-bridge] result handler: session " + session.localId + " cost=" + parsed.cost + " rateLimitResetsAt=" + session.rateLimitResetsAt);
551
604
 
605
+ // A known, named startup failure (e.g. auth, provider_not_allowed) the
606
+ // CLI reports on the error result it writes before exiting -- distinct
607
+ // from an ordinary in-turn execution error, and not gated on a specific
608
+ // subtype since it marks the run never got past startup at all.
609
+ if (parsed.startupFailureReason) {
610
+ console.error("[sdk-bridge] Startup failure for session " + session.localId + ": " + parsed.startupFailureReason);
611
+ session.isProcessing = false;
612
+ session._awaitingTurnResult = false;
613
+ session._queuedTurnCount = 0;
614
+ taskLifecycle.clearAll(session);
615
+ onProcessingChanged();
616
+ sendAndRecord(session, { type: "error", text: "Claude failed to start: " + parsed.startupFailureReason });
617
+ sendAndRecord(session, { type: "done", code: 1 });
618
+ sm.broadcastSessionList();
619
+ return;
620
+ }
621
+
552
622
  // Handle SDK execution errors: show the error to the user instead of
553
623
  // silently swallowing it. These have subtype "error_during_execution".
554
624
  if (parsed.subtype === "error_during_execution") {
@@ -561,6 +631,7 @@ function attachMessageProcessor(ctx) {
561
631
  session.isProcessing = false;
562
632
  session._awaitingTurnResult = false;
563
633
  session._queuedTurnCount = 0;
634
+ taskLifecycle.clearAll(session);
564
635
  onProcessingChanged();
565
636
  if (!overloadAbortRequested) {
566
637
  sendAndRecord(session, { type: "error", text: "Claude error: " + execError });
@@ -768,8 +839,9 @@ function attachMessageProcessor(ctx) {
768
839
  }
769
840
 
770
841
  } else if (parsed.yokeType === "task_notification") {
771
- var parentId = parsed.parentToolId;
772
- if (parentId) {
842
+ var finishedTask = taskLifecycle.finish(session, parsed.parentToolId, parsed.taskId);
843
+ var parentId = finishedTask.parentToolId;
844
+ if (parentId && finishedTask.matched) {
773
845
  sendAndRecord(session, {
774
846
  type: "subagent_done",
775
847
  parentToolId: parentId,
@@ -778,14 +850,6 @@ function attachMessageProcessor(ctx) {
778
850
  usage: parsed.usage || null,
779
851
  });
780
852
  }
781
- if (session.taskIdMap) {
782
- for (var k in session.taskIdMap) {
783
- if (session.taskIdMap[k] === parsed.taskId) {
784
- delete session.taskIdMap[k];
785
- break;
786
- }
787
- }
788
- }
789
853
 
790
854
  } else if (parsed.yokeType === "rate_limit") {
791
855
  var info = parsed.rateLimitInfo;
@@ -965,6 +1029,7 @@ function attachMessageProcessor(ctx) {
965
1029
  session.isProcessing = false;
966
1030
  session._awaitingTurnResult = false;
967
1031
  session._queuedTurnCount = 0;
1032
+ taskLifecycle.clearAll(session);
968
1033
  onProcessingChanged();
969
1034
  emitAuthRequired(session);
970
1035
 
@@ -0,0 +1,52 @@
1
+ function clearAll(session) {
2
+ session.activeTaskToolIds = {};
3
+ session.taskIdMap = {};
4
+ }
5
+
6
+ function clearForeground(session) {
7
+ var backgroundIds = {};
8
+ var backgroundTasks = Array.isArray(session.activeBackgroundTasks) ? session.activeBackgroundTasks : [];
9
+ for (var i = 0; i < backgroundTasks.length; i++) {
10
+ if (backgroundTasks[i] && backgroundTasks[i].task_id) backgroundIds[backgroundTasks[i].task_id] = true;
11
+ }
12
+
13
+ var keptTaskIds = {};
14
+ var keptToolIds = {};
15
+ var taskIdMap = session.taskIdMap || {};
16
+ for (var parentToolId in taskIdMap) {
17
+ var taskId = taskIdMap[parentToolId];
18
+ if (!backgroundIds[taskId]) continue;
19
+ keptTaskIds[parentToolId] = taskId;
20
+ if (session.activeTaskToolIds && session.activeTaskToolIds[parentToolId]) keptToolIds[parentToolId] = true;
21
+ }
22
+ session.activeTaskToolIds = keptToolIds;
23
+ session.taskIdMap = keptTaskIds;
24
+ }
25
+
26
+ function finish(session, parentToolId, taskId) {
27
+ var parentId = parentToolId || null;
28
+ var matched = false;
29
+
30
+ if (parentId && session.activeTaskToolIds && session.activeTaskToolIds[parentId]) {
31
+ matched = true;
32
+ delete session.activeTaskToolIds[parentId];
33
+ }
34
+ if (parentId && session.taskIdMap && session.taskIdMap[parentId]) {
35
+ matched = true;
36
+ delete session.taskIdMap[parentId];
37
+ }
38
+
39
+ if (taskId && session.taskIdMap) {
40
+ for (var key in session.taskIdMap) {
41
+ if (session.taskIdMap[key] !== taskId) continue;
42
+ matched = true;
43
+ delete session.taskIdMap[key];
44
+ if (session.activeTaskToolIds) delete session.activeTaskToolIds[key];
45
+ if (!parentId) parentId = key;
46
+ }
47
+ }
48
+
49
+ return { matched: matched, parentToolId: parentId };
50
+ }
51
+
52
+ module.exports = { clearAll: clearAll, clearForeground: clearForeground, finish: finish };
package/lib/sessions.js CHANGED
@@ -323,7 +323,7 @@ function createSessionManager(opts) {
323
323
  var messageUUIDs = [];
324
324
  for (var k = 0; k < loaded[i].history.length; k++) {
325
325
  if (loaded[i].history[k].type === "message_uuid") {
326
- messageUUIDs.push({ uuid: loaded[i].history[k].uuid, type: loaded[i].history[k].messageType, historyIndex: k });
326
+ messageUUIDs.push({ uuid: loaded[i].history[k].uuid, type: loaded[i].history[k].messageType, historyIndex: k, turnId: loaded[i].history[k].turnId || null });
327
327
  }
328
328
  }
329
329
  var hasTitleProvenance = Object.prototype.hasOwnProperty.call(m, "titleProvisional")
@@ -337,6 +337,9 @@ function canUseTool(toolName, input, opts) {
337
337
  decisionReason: opts.decisionReason || "",
338
338
  defaultToNo: opts.defaultToNo === true,
339
339
  suppressAlwaysAllowRule: opts.suppressAlwaysAllowRule === true,
340
+ mcpServer: (opts.mcpServer && typeof opts.mcpServer === "object")
341
+ ? { name: opts.mcpServer.name || null, source: opts.mcpServer.source || null }
342
+ : null,
340
343
  });
341
344
  return new Promise(function(resolve) {
342
345
  pendingPermissions[requestId] = resolve;
@@ -257,6 +257,7 @@ function flattenEvent(raw) {
257
257
  if (raw.resume_reason) base.resumeReason = raw.resume_reason;
258
258
  if (Number.isInteger(raw.result_index)) base.resultIndex = raw.result_index;
259
259
  if (raw.local_command) base.localCommand = raw.local_command;
260
+ if (raw.origin && typeof raw.origin === "object") base.origin = raw.origin;
260
261
  if (raw.session_id) base.sessionId = raw.session_id;
261
262
  if (raw.uuid) {
262
263
  base.uuid = raw.uuid;
@@ -342,6 +343,10 @@ function flattenEvent(raw) {
342
343
  base.slashCommands = raw.slash_commands;
343
344
  base.fastModeState = raw.fast_mode_state || null;
344
345
  base.permissionMode = raw.permissionMode || raw.permission_mode || null;
346
+ // SDK 0.3.283: entries for a --plugin-dir that did not load, each with
347
+ // a `path`. Forwarded so the user can see a plugin silently failed
348
+ // instead of finding its commands/skills simply missing.
349
+ base.pluginErrors = Array.isArray(raw.plugin_errors) ? raw.plugin_errors : null;
345
350
  return base;
346
351
  }
347
352
  if (raw.subtype === "status") {
@@ -392,7 +397,7 @@ function flattenEvent(raw) {
392
397
  }
393
398
  if (raw.subtype === "task_notification") {
394
399
  base.yokeType = "task_notification";
395
- base.parentToolId = raw.parent_tool_use_id;
400
+ base.parentToolId = raw.tool_use_id || raw.parent_tool_use_id;
396
401
  base.taskId = raw.task_id;
397
402
  base.status = raw.status || "completed";
398
403
  base.summary = raw.summary || "";
@@ -475,6 +480,8 @@ function flattenEvent(raw) {
475
480
  if (raw.type === "result") {
476
481
  base.yokeType = "result";
477
482
  base.is_error = raw.is_error === true;
483
+ base.numTurns = Number.isInteger(raw.num_turns) ? raw.num_turns : null;
484
+ base.resultText = typeof raw.result === "string" ? raw.result : null;
478
485
  if (Number.isInteger(raw.queued_turn_count) && raw.queued_turn_count >= 0) base.queuedTurnCount = raw.queued_turn_count;
479
486
  base.cost = raw.total_cost_usd;
480
487
  base.duration = raw.duration_ms;
@@ -483,6 +490,10 @@ function flattenEvent(raw) {
483
490
  base.subtype = raw.subtype;
484
491
  base.errors = raw.errors;
485
492
  base.terminalReason = raw.terminal_reason;
493
+ // SDK 0.3.280: a known, named reason a stream-json run failed to start
494
+ // (e.g. auth, provider_not_allowed) carried on the error result the CLI
495
+ // writes before exiting, distinct from an ordinary in-turn execution error.
496
+ base.startupFailureReason = raw.startup_failure_reason || null;
486
497
  base.fastModeState = raw.fast_mode_state || null;
487
498
  return base;
488
499
  }
@@ -499,6 +510,17 @@ function flattenEvent(raw) {
499
510
  base.yokeType = "message";
500
511
  base.messageRole = raw.type;
501
512
  base.content = raw.message ? raw.message.content : null;
513
+ // Structured sibling metadata for the single tool_result this message
514
+ // carries (SDK type docs: "the tool's full Output object, not the string
515
+ // content sent to the model" -- never the text Clay actually renders).
516
+ // Its only use here is detecting `{ detachedToolCall: true }`: a call
517
+ // that stepped aside for a priority message, whose real result "reaches
518
+ // the model in a later turn" under the same tool_use_id.
519
+ base.toolUseResult = raw.tool_use_result || null;
520
+ if (raw.tool_use_result && raw.tool_use_result.detachedToolCall === true && Array.isArray(base.content)) {
521
+ var detachedResults = base.content.filter(function(block) { return block && block.type === "tool_result"; });
522
+ if (detachedResults.length === 1) base.detachedToolId = detachedResults[0].tool_use_id || null;
523
+ }
502
524
  return base;
503
525
  }
504
526
 
@@ -519,7 +541,7 @@ function flattenEvent(raw) {
519
541
  // --- task_notification ---
520
542
  if (raw.type === "task_notification") {
521
543
  base.yokeType = "task_notification";
522
- base.parentToolId = raw.parent_tool_use_id;
544
+ base.parentToolId = raw.tool_use_id || raw.parent_tool_use_id;
523
545
  base.taskId = raw.task_id;
524
546
  base.status = raw.status || "completed";
525
547
  base.summary = raw.summary || "";
@@ -1134,6 +1156,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool,
1134
1156
  decisionReason: msg.decisionReason,
1135
1157
  defaultToNo: msg.defaultToNo === true,
1136
1158
  suppressAlwaysAllowRule: msg.suppressAlwaysAllowRule === true,
1159
+ mcpServer: msg.mcpServer || null,
1137
1160
  signal: interactionAbort.signal,
1138
1161
  }).then(function(result) {
1139
1162
  if (interactionAbort.signal.aborted) result = { behavior: "deny", message: "Query cancelled" };
@@ -190,7 +190,10 @@ function flattenEvent(notification, state) {
190
190
  state.lastOutputTokens = null;
191
191
  state.currentContextUsage = null;
192
192
  var userUuid = generateUuid();
193
- events.push({ yokeType: "turn_start", uuid: userUuid, messageType: "user" });
193
+ // turnId (Codex's own id, distinct from Clay's local uuid) is required
194
+ // by thread/revert's beforeTurnId; without it this turn can never be
195
+ // the target of a later rollback.
196
+ events.push({ yokeType: "turn_start", uuid: userUuid, messageType: "user", turnId: state.currentTurnId });
194
197
  return events;
195
198
  }
196
199
 
@@ -2145,14 +2148,37 @@ function createCodexAdapter(opts) {
2145
2148
  return { sessionId: newThreadId };
2146
2149
  });
2147
2150
  },
2148
- rollbackThread: function(threadId, numTurns, opts) {
2151
+ // Codex 0.160+ replaced the count-based `thread/rollback` (threadId,
2152
+ // numTurns) with `thread/revert` (threadId, beforeTurnId): the turn
2153
+ // excluded from the replacement history, together with every later turn.
2154
+ rollbackThread: async function(threadId, beforeTurnId, opts) {
2149
2155
  var requestedLinuxUser = opts && opts.linuxUser;
2150
2156
  if (!_runtimeLinuxUser && requestedLinuxUser) {
2151
- return getUserRuntime(requestedLinuxUser).rollbackThread(threadId, numTurns, withoutLinuxUser(opts));
2157
+ return getUserRuntime(requestedLinuxUser).rollbackThread(threadId, beforeTurnId, withoutLinuxUser(opts));
2152
2158
  }
2153
2159
  if (_requiresLinuxUser) return Promise.reject(new Error("Codex requires a mapped Linux user while OS-user isolation is enabled"));
2154
- if (!_appServer || !_appServer.started) return Promise.resolve(null);
2155
- return _appServer.send("thread/rollback", { threadId: threadId, numTurns: numTurns }, 30000);
2160
+ // A missing/stopped app-server means the revert was never sent, so the
2161
+ // Codex thread was never touched. Resolving null here (as this used to)
2162
+ // reads as success to the caller, which then trims Clay's own local
2163
+ // history to match a rollback that never happened. Lazily (re)start the
2164
+ // app-server first, as createQuery does, and only fail if that does not
2165
+ // bring it up -- never silently report success without sending anything.
2166
+ if (!_appServer || !_appServer.started) await adapter.init(opts || {});
2167
+ if (!_appServer || !_appServer.started) {
2168
+ throw new Error("Codex app-server is not running; the thread was not rolled back.");
2169
+ }
2170
+ var revertResult = await _appServer.send("thread/revert", { threadId: threadId, beforeTurnId: beforeTurnId }, 30000);
2171
+ // Confirm the server actually reports the exact thread it was asked to
2172
+ // cut, rather than trusting any truthy response: ThreadRevertResponse's
2173
+ // `thread` field is required and always carries that thread's own `id`
2174
+ // (per the app-server's generated protocol schema). A response for a
2175
+ // different thread id -- e.g. misrouted after a resume/fork -- or a
2176
+ // malformed/empty response is treated the same as a failure, never as
2177
+ // a silent success.
2178
+ if (!revertResult || !revertResult.thread || revertResult.thread.id !== threadId) {
2179
+ throw new Error("Codex did not confirm the thread revert.");
2180
+ }
2181
+ return revertResult;
2156
2182
  },
2157
2183
 
2158
2184
  // Shutdown the app-server process
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.7.0-beta.5",
3
+ "version": "4.7.0",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"
@@ -47,9 +47,9 @@
47
47
  "homepage": "https://github.com/chadbyte/clay#readme",
48
48
  "author": "Chad",
49
49
  "dependencies": {
50
- "@anthropic-ai/claude-agent-sdk": "^0.3.272",
50
+ "@anthropic-ai/claude-agent-sdk": "^0.3.287",
51
51
  "@lydell/node-pty": "^1.2.0-beta.3",
52
- "@openai/codex": "^0.154.0",
52
+ "@openai/codex": "^0.160.0",
53
53
  "@seald-io/nedb": "^4.1.2",
54
54
  "imapflow": "^1.7.8",
55
55
  "nodemailer": "^9.1.1",