clay-server 4.7.0-beta.2 → 4.7.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/public/app.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { initGenerationStop } from './modules/generation-stop.js';
1
2
  import { initIssueLinks } from './modules/clay-issue-links.js';
2
3
  import { initIssues, closeIssues } from './modules/issues.js';
3
4
  import { initIssuesContext } from './modules/issues-context.js';
@@ -821,6 +822,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
821
822
 
822
823
  // --- Notifications module ---
823
824
  initAppNotifications();
825
+ initGenerationStop();
824
826
 
825
827
  // --- Panels module ---
826
828
  initPanels();
@@ -1717,3 +1717,30 @@ body.tui-composer-active #config-chip-wrap {
1717
1717
  font-weight: 600;
1718
1718
  color: inherit;
1719
1719
  }
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; }
1730
+ #generation-stop {
1731
+ display: inline-flex;
1732
+ align-items: center;
1733
+ gap: 6px;
1734
+ min-height: 36px;
1735
+ padding: 6px 12px;
1736
+ border: 1px solid var(--border);
1737
+ border-radius: 18px;
1738
+ background: var(--sidebar-bg);
1739
+ color: var(--text-secondary);
1740
+ font: inherit;
1741
+ font-size: 12px;
1742
+ cursor: pointer;
1743
+ }
1744
+ #generation-stop:hover:not(:disabled) { color: var(--text); background: var(--code-bg); }
1745
+ #generation-stop:disabled { opacity: 0.5; cursor: default; }
1746
+ #generation-stop .lucide { width: 12px; height: 12px; }
@@ -635,6 +635,9 @@
635
635
  </div>
636
636
  </div>
637
637
  </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>
638
641
  </div>
639
642
  </div>
640
643
  <!-- Mobile input-more bottom sheet: attach / image + context sources -->
@@ -151,7 +151,19 @@ function suspendSocketWatchdogs() {
151
151
  watchdog.suspend();
152
152
  }
153
153
 
154
+ function refreshBackgroundPane() {
155
+ if (document.hidden || !store.get('paneNeedsSnapshot')) return false;
156
+ store.set({ paneNeedsSnapshot: false });
157
+ if (!store.get('paneMode') || !store.get('paneSessionId') || lifecycle.isOffline()) return false;
158
+ // A fresh socket discards browser-queued deltas and re-pins the exact pane.
159
+ // Server replay supplies current history, status and pending interactions.
160
+ watchdog.resume();
161
+ connect();
162
+ return true;
163
+ }
164
+
154
165
  function resumeSocketWatchdogs(reason) {
166
+ if (refreshBackgroundPane()) return;
155
167
  var resumed = watchdog.resume();
156
168
  var socket = getWs();
157
169
  if (socket && socket.readyState === 1 && resumed) startHeartbeat(socket, lifecycle.getEpoch());
@@ -178,10 +190,13 @@ export function initConnection() {
178
190
  });
179
191
  });
180
192
  document.addEventListener("visibilitychange", function () {
181
- if (document.hidden) suspendSocketWatchdogs();
182
- else resumeSocketWatchdogs("visible");
193
+ if (document.hidden) {
194
+ if (store.get('paneMode') && store.get('paneSessionId')) store.set({ paneNeedsSnapshot: true });
195
+ suspendSocketWatchdogs();
196
+ } else resumeSocketWatchdogs("visible");
183
197
  });
184
198
  document.addEventListener("freeze", function () {
199
+ if (store.get('paneMode') && store.get('paneSessionId')) store.set({ paneNeedsSnapshot: true });
185
200
  suspendSocketWatchdogs();
186
201
  });
187
202
  document.addEventListener("resume", function () {
@@ -387,6 +402,12 @@ export function connect() {
387
402
 
388
403
  newWs.onmessage = function (event) {
389
404
  if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
405
+ // Hidden pane deltas are replaced by a current server snapshot on return.
406
+ if (store.get('paneMode') && store.get('paneSessionId') && document.hidden) {
407
+ store.set({ paneNeedsSnapshot: true });
408
+ return;
409
+ }
410
+ if (refreshBackgroundPane()) return;
390
411
  // Backup: if we're receiving messages, we're connected
391
412
  if (!store.get('connected')) {
392
413
  setStatus("connected");
@@ -83,6 +83,7 @@ export function drawFaviconAnimFrame() {
83
83
 
84
84
  export function setSendBtnMode(mode) {
85
85
  var sendBtn = getSendBtn();
86
+ sendBtn.setAttribute("aria-label", mode === "stop" ? "Stop generating" : "Send");
86
87
  if (mode === "stop") {
87
88
  sendBtn.disabled = false;
88
89
  sendBtn.classList.add("stop");
@@ -66,7 +66,7 @@ import { renderGeneratedImage, renderImageGenerationProgress, clearImageGenerati
66
66
  import { clearPairResultStatus, handlePairResultStatus } from './pair-result-status.js';
67
67
 
68
68
  // --- App module imports ---
69
- import { scrollToBottom, addToMessages, addUserMessage, addSystemMessage, removeMatePreThinking, appendDelta, finalizeAssistantBlock, addConflictMessage, addContextOverflowMessage, showSuggestionChips, armStickyBottom, VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
69
+ import { scrollToBottom, addToMessages, addUserMessage, addSystemMessage, removeMatePreThinking, resetStreamBuffer, appendDelta, finalizeAssistantBlock, addConflictMessage, addContextOverflowMessage, showSuggestionChips, armStickyBottom, VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
70
70
  import { setActivity, startUrgentBlink, stopUrgentBlink, blinkSessionDot, updateCrossProjectBlink } from './app-favicon.js';
71
71
  import { setStatus } from './app-connection.js';
72
72
  import { resolvePaneSession, resolveSwitchedVendor } from './pane-session.js';
@@ -834,6 +834,7 @@ export function processMessage(msg) {
834
834
 
835
835
  case "session_switched":
836
836
  clearPairResultStatus();
837
+ resetStreamBuffer();
837
838
  syncAutonomousRunForSession(msg.autonomousRun || null);
838
839
  // Save draft from outgoing session
839
840
  var _prevSid = store.get('activeSessionId');
@@ -45,6 +45,11 @@ export function addCopyHandler(msgEl, rawText) {
45
45
  export function appendDelta(text) {
46
46
  ensureAssistantBlock();
47
47
  streamBuffer += text;
48
+ if (store.get('replayingHistory')) {
49
+ store.set({ currentFullText: store.get('currentFullText') + streamBuffer });
50
+ streamBuffer = "";
51
+ return;
52
+ }
48
53
  if (!streamDrainTimer) {
49
54
  streamDrainTimer = requestAnimationFrame(drainStreamTick);
50
55
  }
@@ -55,16 +60,9 @@ function drainStreamTick() {
55
60
  var _s = store.snap();
56
61
  if (!_s.currentMsgEl || streamBuffer.length === 0) return;
57
62
 
58
- var n;
59
- var len = streamBuffer.length;
60
- if (len > 200) { n = Math.ceil(len / 4); }
61
- else if (len > 80) { n = 8; }
62
- else if (len > 30) { n = 5; }
63
- else if (len > 10) { n = 2; }
64
- else { n = 1; }
65
-
66
- var chunk = streamBuffer.slice(0, n);
67
- streamBuffer = streamBuffer.slice(n);
63
+ // Render everything received by this frame; never replay a typing backlog.
64
+ var chunk = streamBuffer;
65
+ streamBuffer = "";
68
66
  var newText = _s.currentFullText + chunk;
69
67
  store.set({ currentFullText: newText });
70
68
 
@@ -82,6 +80,14 @@ function drainStreamTick() {
82
80
  }
83
81
  }
84
82
 
83
+ export function resetStreamBuffer() {
84
+ if (streamDrainTimer) cancelAnimationFrame(streamDrainTimer);
85
+ if (highlightTimer) clearTimeout(highlightTimer);
86
+ streamDrainTimer = null;
87
+ highlightTimer = null;
88
+ streamBuffer = "";
89
+ }
90
+
85
91
  export function flushStreamBuffer() {
86
92
  if (streamDrainTimer) { cancelAnimationFrame(streamDrainTimer); streamDrainTimer = null; }
87
93
  if (streamBuffer.length > 0) {
@@ -0,0 +1,24 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+
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.
6
+ export function initGenerationStop() {
7
+ var row = document.getElementById('generation-stop-row');
8
+ var button = document.getElementById('generation-stop');
9
+ if (!row || !button) return;
10
+ function sync() {
11
+ row.classList.toggle('hidden', !store.get('processing'));
12
+ button.disabled = !store.get('connected');
13
+ button.title = store.get('connected') ? 'Stop generating' : 'Reconnect to stop generating';
14
+ }
15
+ button.addEventListener('click', function () {
16
+ var ws = getWs();
17
+ if (!store.get('processing') || !store.get('connected') || !ws || ws.readyState !== 1) return;
18
+ ws.send(JSON.stringify({ type: 'stop' }));
19
+ });
20
+ store.subscribe(function (state, previous) {
21
+ if (state.processing !== previous.processing || state.connected !== previous.connected) sync();
22
+ });
23
+ sync();
24
+ }
package/lib/sdk-bridge.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const crypto = require("crypto");
2
+ var codexQuestionGuidance = require("./yoke/user-input-guidance").codexQuestionGuidance;
2
3
  var fs = require("fs");
3
4
  var path = require("path");
4
5
  var execSync = require("child_process").execSync;
@@ -1054,6 +1055,10 @@ function createSDKBridge(opts) {
1054
1055
  console.log("[sdk-bridge] processQueryStream: starting for-await loop, vendor=" + (session.vendor || adapter.vendor));
1055
1056
  try {
1056
1057
  for await (var msg of myQueryInstance) {
1058
+ // An old iterator may drain after Stop or after a replacement query starts.
1059
+ if (!ownsQueryRuntime(session, myQueryInstance, myQueryGeneration)) continue;
1060
+ if ((session.taskStopRequested || (myAbortController && myAbortController.signal.aborted))
1061
+ && msg && msg.yokeType !== "error" && msg.yokeType !== "interrupted") continue;
1057
1062
  if (msg && msg.yokeType !== "text_delta" && msg.yokeType !== "thinking_delta" && msg.yokeType !== "tool_input_delta") {
1058
1063
  console.log("[sdk-bridge] processQueryStream: received event yokeType=" + msg.yokeType);
1059
1064
  }
@@ -1111,6 +1116,7 @@ function createSDKBridge(opts) {
1111
1116
  && session.cliSessionId === msg.sessionId
1112
1117
  && (!sm.sessions || typeof sm.sessions.get !== "function" || sm.sessions.get(session.localId) === session)) {
1113
1118
  if (codexCatalogCandidate.version) session.codexDynamicToolCatalogVersion = codexCatalogCandidate.version;
1119
+ if (codexCatalogCandidate.userInputVersion) session.codexUserInputToolCatalogVersion = codexCatalogCandidate.userInputVersion;
1114
1120
  if (codexCatalogCandidate.issuesVersion) session.codexIssuesToolCatalogVersion = codexCatalogCandidate.issuesVersion;
1115
1121
  sm.saveSessionFile(session);
1116
1122
  codexCatalogCandidate = null;
@@ -1804,8 +1810,10 @@ function createSDKBridge(opts) {
1804
1810
  var activeLoopInterview = !isDriverOperatedSession(session) && !session.loopInterviewHandoff && !activeRun && !!loopInterview.activeInterviewId(session);
1805
1811
  var structuredInputSession = hasScheduleInterviewTool || activeLoopInterview || (!isDriverOperatedSession(session) && session.ralphCraftingMode === true && session.loop && session.loop.role === "crafting");
1806
1812
  var requestedUserInputMode = session.debateSetupMode || session.mateCreationMode ? "fallback" : "auto";
1807
- if (structuredInputSession) requestedUserInputMode = "fallback";
1813
+ if (structuredInputSession || sessionAdapter.vendor === "codex") requestedUserInputMode = "fallback";
1808
1814
  var selectedUserInputMode = yoke.userInput.selectMode(sessionAdapter, requestedUserInputMode);
1815
+ var recoverCodexQuestionTools = sessionAdapter.vendor === "codex" && !!session.cliSessionId
1816
+ && Number(session.codexUserInputToolCatalogVersion || 0) < 1 && queryContextAvailable();
1809
1817
  function structuredInputContextAvailable() {
1810
1818
  if (!queryContextAvailable()) return false;
1811
1819
  // A schedule-triggered fallback shares the schedule feature's live
@@ -1928,7 +1936,10 @@ function createSDKBridge(opts) {
1928
1936
  // would strip the driver session down to the two-sentence pair preset.
1929
1937
  // Adapters map this to their additive mechanism (claude: preset+append,
1930
1938
  // codex/kiro: prepend to the first message).
1931
- appendSystemPrompt: typeof opts.getSessionSystemPrompt === "function" ? (opts.getSessionSystemPrompt(session) || undefined) : undefined,
1939
+ appendSystemPrompt: [
1940
+ typeof opts.getSessionSystemPrompt === "function" ? opts.getSessionSystemPrompt(session) : "",
1941
+ sessionAdapter.vendor === "codex" && queryContextAvailable() ? codexQuestionGuidance() : "",
1942
+ ].filter(Boolean).join("\n\n") || undefined,
1932
1943
  model: queryModel,
1933
1944
  // Effort is remembered per vendor and clamped to the session vendor's
1934
1945
  // supported levels: a global value would leak codex "minimal" into
@@ -1946,7 +1957,7 @@ function createSDKBridge(opts) {
1946
1957
  dynamicTools: sessionToolDefs,
1947
1958
  // Codex persists dynamic tools with a thread. A stale authorized Driver
1948
1959
  // gets this exact-query bridge; its handlers still reauthorize live.
1949
- sessionMcpServer: recoverCodexIssueTools ? getCodexSessionMcpServer(session, queryGeneration) : undefined,
1960
+ sessionMcpServer: (recoverCodexIssueTools || recoverCodexQuestionTools) ? getCodexSessionMcpServer(session, queryGeneration) : undefined,
1950
1961
  callDynamicTool: function (toolName, args) {
1951
1962
  var toolDef = findSessionToolDef(toolName);
1952
1963
  if (toolDef && typeof toolDef.handler === "function") {
@@ -2159,6 +2170,9 @@ function createSDKBridge(opts) {
2159
2170
  if (completeInterviewCatalog || requiredCatalogTools.report_scheduled_task_outcome) {
2160
2171
  codexCatalogCandidate = { generation: queryGeneration, version: yoke.sessionTools.CODEX_CATALOG_VERSION };
2161
2172
  }
2173
+ if (requiredCatalogTools.ask_user_questions) {
2174
+ codexCatalogCandidate = Object.assign({}, codexCatalogCandidate || {}, { generation: queryGeneration, userInputVersion: 1 });
2175
+ }
2162
2176
  if (hasIssueCatalog) {
2163
2177
  codexCatalogCandidate = Object.assign({}, codexCatalogCandidate || {}, {
2164
2178
  generation: queryGeneration,
package/lib/sessions.js CHANGED
@@ -180,6 +180,7 @@ function createSessionManager(opts) {
180
180
  if (session.ownerId) metaObj.ownerId = session.ownerId;
181
181
  if (session.vendor) metaObj.vendor = session.vendor;
182
182
  if (session.codexDynamicToolCatalogVersion) metaObj.codexDynamicToolCatalogVersion = session.codexDynamicToolCatalogVersion;
183
+ if (session.codexUserInputToolCatalogVersion) metaObj.codexUserInputToolCatalogVersion = session.codexUserInputToolCatalogVersion;
183
184
  if (session.codexIssuesToolCatalogVersion) metaObj.codexIssuesToolCatalogVersion = session.codexIssuesToolCatalogVersion;
184
185
  if (session.model) metaObj.model = session.model;
185
186
  if (session.effort) metaObj.effort = session.effort;
@@ -354,6 +355,7 @@ function createSessionManager(opts) {
354
355
  };
355
356
  if (m.vendor) session.vendor = m.vendor;
356
357
  session.codexDynamicToolCatalogVersion = Number(m.codexDynamicToolCatalogVersion || 0);
358
+ session.codexUserInputToolCatalogVersion = Number(m.codexUserInputToolCatalogVersion || 0);
357
359
  session.codexIssuesToolCatalogVersion = Number(m.codexIssuesToolCatalogVersion || 0);
358
360
  session.model = m.model || deriveSessionModel(loaded[i].history);
359
361
  session.effort = m.effort || null;
@@ -188,33 +188,41 @@ AcpProcessManager.prototype.addRequestHandler = function(method, fn) {
188
188
  this.requestHandlers[method] = fn;
189
189
  };
190
190
 
191
- AcpProcessManager.prototype.send = function(method, params, timeoutMs) {
191
+ AcpProcessManager.prototype.send = function(method, params, timeoutMs, signal) {
192
192
  var self = this;
193
193
  var id = this.nextId++;
194
194
  timeoutMs = timeoutMs || 30000;
195
-
196
195
  return new Promise(function(resolve, reject) {
197
- if (!self.proc || !self.started) {
198
- reject(new Error("ACP server not started"));
199
- return;
200
- }
201
- var timer = setTimeout(function() {
196
+ var timer;
197
+ function cleanup() {
198
+ if (timer) clearTimeout(timer);
202
199
  delete self.pendingRequests[id];
203
- reject(new Error("Request timeout: " + method + " (id=" + id + ")"));
204
- }, timeoutMs);
205
- self.pendingRequests[id] = { resolve: resolve, reject: reject, timer: timer };
206
-
200
+ if (signal) signal.removeEventListener("abort", cancel);
201
+ }
202
+ function fail(error) { cleanup(); reject(error); }
203
+ function cancel() {
204
+ var error = new Error("ACP request cancelled: " + method);
205
+ error.name = "AbortError";
206
+ fail(error);
207
+ }
208
+ if (signal && signal.aborted) { cancel(); return; }
209
+ if (!self.proc || !self.started) { fail(new Error("ACP server not started")); return; }
210
+ timer = setTimeout(function() { fail(new Error("Request timeout: " + method + " (id=" + id + ")")); }, timeoutMs);
211
+ self.pendingRequests[id] = {
212
+ resolve: function (value) { cleanup(); resolve(value); }, reject: fail, timer: timer,
213
+ };
214
+ if (signal) signal.addEventListener("abort", cancel, { once: true });
207
215
  var msg = { jsonrpc: "2.0", id: id, method: method };
208
216
  if (params !== undefined) msg.params = params;
209
- self._write(msg);
217
+ if (self._write(msg) === false) fail(new Error("ACP transport is not writable"));
210
218
  });
211
219
  };
212
220
 
213
221
  AcpProcessManager.prototype.notify = function(method, params) {
214
- if (!this.proc || !this.started) return;
222
+ if (!this.proc || !this.started) return false;
215
223
  var msg = { jsonrpc: "2.0", method: method };
216
224
  if (params !== undefined) msg.params = params;
217
- this._write(msg);
225
+ return this._write(msg);
218
226
  };
219
227
 
220
228
  AcpProcessManager.prototype.respond = function(id, result) {
@@ -228,11 +236,13 @@ AcpProcessManager.prototype.respondError = function(id, code, message) {
228
236
  };
229
237
 
230
238
  AcpProcessManager.prototype._write = function(msg) {
231
- if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
239
+ if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return false;
232
240
  try {
233
241
  this.proc.stdin.write(JSON.stringify(msg) + "\n");
242
+ return true;
234
243
  } catch (e) {
235
244
  this._error("Write error:", e.message);
245
+ return false;
236
246
  }
237
247
  };
238
248
 
@@ -1,29 +1,33 @@
1
1
  // Shared ACP Query Handle
2
2
  // -----------------------
3
3
  // Implements the YOKE QueryHandle contract with standard ACP session methods.
4
-
5
4
  var normalizer = require("./acp-event-normalizer");
6
5
  var driverRuntime = require("./acp-driver-runtime");
7
-
6
+ var queryAbort = require("./query-abort");
8
7
  var uuidCounter = 0;
9
8
  function generateUuid(vendor) {
10
9
  uuidCounter++;
11
10
  return (vendor || "acp") + "-" + Date.now().toString(36) + "-" + uuidCounter.toString(36);
12
11
  }
13
-
14
12
  function isAuthError(value) {
15
13
  return /not logged in|sign in|log ?in|authenticate|authentication|credentials|unauthorized|forbidden|\b401\b/i.test(String(value || ""));
16
14
  }
17
-
18
15
  function isApproved(decision) {
19
16
  return decision === true || (decision && decision.behavior === "allow");
20
17
  }
21
-
22
18
  function createAcpQueryHandle(acp, queryOpts) {
23
19
  queryOpts = queryOpts || {};
24
20
  var vendor = queryOpts.vendor || "acp";
25
21
  var driver = queryOpts.driver || null;
26
22
  var abortController = queryOpts.abortController;
23
+ var cancellation = queryAbort.createAbortScope();
24
+ var removeAbort = function () {};
25
+ var pendingPermissions = new Set();
26
+ var stopDelivery = Promise.resolve();
27
+ function send(method, params, timeout) {
28
+ cancellation.check();
29
+ return cancellation.wait(acp.send(method, params, timeout, cancellation.signal));
30
+ }
27
31
  var canUseTool = queryOpts.canUseTool || null;
28
32
  var state = normalizer.createEventState({ vendor: vendor, contextWindow: queryOpts.contextWindow });
29
33
  state.sessionId = queryOpts.resumeSessionId || null;
@@ -76,6 +80,7 @@ function createAcpQueryHandle(acp, queryOpts) {
76
80
  function endIterator() {
77
81
  if (iteratorDone) return;
78
82
  iteratorDone = true;
83
+ removeAbort();
79
84
  if (eventWaiting) {
80
85
  var resolve = eventWaiting;
81
86
  eventWaiting = null;
@@ -128,7 +133,19 @@ function createAcpQueryHandle(acp, queryOpts) {
128
133
  return;
129
134
  }
130
135
 
136
+ var answered = false;
137
+ function cancelPermission() {
138
+ if (answered) return;
139
+ answered = true;
140
+ pendingPermissions.delete(cancelPermission);
141
+ acp.respond(msg.id, { outcome: { outcome: "cancelled" } });
142
+ }
143
+ pendingPermissions.add(cancelPermission);
131
144
  function respond(allowed) {
145
+ if (answered) return;
146
+ if (isCancelled()) { cancelPermission(); return; }
147
+ answered = true;
148
+ pendingPermissions.delete(cancelPermission);
132
149
  var optionId = allowed ? allowId : rejectId;
133
150
  if (!optionId) {
134
151
  acp.respond(msg.id, { outcome: { outcome: "cancelled" } });
@@ -160,7 +177,7 @@ function createAcpQueryHandle(acp, queryOpts) {
160
177
  input: input,
161
178
  options: options,
162
179
  }), function() { return { toolName: toolName, input: input }; }) || { toolName: toolName, input: input };
163
- Promise.resolve(canUseTool(permission.toolName || toolName, permission.input || input, {})).then(function(decision) {
180
+ Promise.resolve(canUseTool(permission.toolName || toolName, permission.input || input, { signal: cancellation.signal })).then(function(decision) {
164
181
  respond(isApproved(decision));
165
182
  }).catch(function(err) {
166
183
  console.error("[yoke/" + vendor + "] canUseTool error:", err.message || err);
@@ -193,7 +210,7 @@ function createAcpQueryHandle(acp, queryOpts) {
193
210
  if (!state.sessionId || !acp.started || value === undefined || value === null) return Promise.resolve();
194
211
  var option = configOptionFor(category, fallbackId);
195
212
  if (option) {
196
- return acp.send("session/set_config_option", {
213
+ return send("session/set_config_option", {
197
214
  sessionId: state.sessionId,
198
215
  configId: option.id,
199
216
  value: value,
@@ -202,7 +219,7 @@ function createAcpQueryHandle(acp, queryOpts) {
202
219
  });
203
220
  }
204
221
  if (category === "mode") {
205
- return acp.send("session/set_mode", { sessionId: state.sessionId, modeId: value }, 15000);
222
+ return send("session/set_mode", { sessionId: state.sessionId, modeId: value }, 15000);
206
223
  }
207
224
  return Promise.reject(new Error("ACP session does not expose configuration option: " + fallbackId));
208
225
  }
@@ -230,7 +247,7 @@ function createAcpQueryHandle(acp, queryOpts) {
230
247
  if (safeModeValue([modes.currentModeId])) return Promise.resolve();
231
248
  var safeMode = safeModeValue(modes.availableModes || []);
232
249
  if (!safeMode) return Promise.reject(new Error("ACP agent started in an unsafe permission mode and exposed no supervised mode"));
233
- return acp.send("session/set_mode", { sessionId: state.sessionId, modeId: safeMode }, 15000);
250
+ return send("session/set_mode", { sessionId: state.sessionId, modeId: safeMode }, 15000);
234
251
  }
235
252
  if (driver && driver.permissionModeGuaranteed === true) return Promise.resolve();
236
253
  return Promise.reject(new Error("ACP agent did not expose a verifiable supervised permission mode"));
@@ -302,6 +319,7 @@ function createAcpQueryHandle(acp, queryOpts) {
302
319
  }
303
320
 
304
321
  function applySessionResult(result) {
322
+ cancellation.check();
305
323
  result = result || {};
306
324
  if (result.sessionId) state.sessionId = result.sessionId;
307
325
  if (Array.isArray(result.configOptions)) state.configOptions = result.configOptions;
@@ -313,7 +331,7 @@ function createAcpQueryHandle(acp, queryOpts) {
313
331
  async function openDefaultSession(handlerEntry) {
314
332
  if (state.sessionId && queryOpts.canResumeSession) {
315
333
  handlerEntry.sessionId = state.sessionId;
316
- var resumed = await acp.send("session/resume", buildSessionParams("resume", {
334
+ var resumed = await send("session/resume", buildSessionParams("resume", {
317
335
  sessionId: state.sessionId,
318
336
  cwd: queryOpts.cwd,
319
337
  mcpServers: queryOpts.mcpServers || [],
@@ -324,7 +342,7 @@ function createAcpQueryHandle(acp, queryOpts) {
324
342
  handlerEntry.sessionId = state.sessionId;
325
343
  state.loadingSession = true;
326
344
  try {
327
- var loaded = await acp.send("session/load", buildSessionParams("load", {
345
+ var loaded = await send("session/load", buildSessionParams("load", {
328
346
  sessionId: state.sessionId,
329
347
  cwd: queryOpts.cwd,
330
348
  mcpServers: queryOpts.mcpServers || [],
@@ -336,7 +354,7 @@ function createAcpQueryHandle(acp, queryOpts) {
336
354
  }
337
355
 
338
356
  state.sessionId = null;
339
- var result = await acp.send("session/new", buildSessionParams("new", {
357
+ var result = await send("session/new", buildSessionParams("new", {
340
358
  cwd: queryOpts.cwd,
341
359
  mcpServers: queryOpts.mcpServers || [],
342
360
  }), 60000);
@@ -355,7 +373,7 @@ function createAcpQueryHandle(acp, queryOpts) {
355
373
  sessionId: state.sessionId,
356
374
  prompt: asPrompt(message),
357
375
  });
358
- return acp.send("session/prompt", params, 30 * 60 * 1000);
376
+ return send("session/prompt", params, 30 * 60 * 1000);
359
377
  });
360
378
  }
361
379
 
@@ -363,17 +381,17 @@ function createAcpQueryHandle(acp, queryOpts) {
363
381
  var currentMessage = prependSystemPrompt(initialMessage);
364
382
  var handlerEntry = acp.addHandler(handleServerEvent);
365
383
  try {
366
- var sessionResult = await openSession(handlerEntry);
384
+ var sessionResult = await cancellation.wait(openSession(handlerEntry));
367
385
  handlerEntry.sessionId = state.sessionId;
368
- await driverRuntime.callAsync(driver, "afterSessionOpen", driverContext({ sessionResult: sessionResult }), function() {});
369
- await ensureSafePermissionMode();
386
+ await cancellation.wait(driverRuntime.callAsync(driver, "afterSessionOpen", driverContext({ sessionResult: sessionResult }), function() {}));
387
+ await cancellation.wait(ensureSafePermissionMode());
370
388
  if (state.model && state.model !== "auto") await setModel(state.model);
371
389
  if (queryOpts.mode) await setConfig("mode", "mode", queryOpts.mode);
372
390
 
373
391
  while (!isCancelled()) {
374
392
  normalizer.resetTurnState(state);
375
393
  pushEvent({ yokeType: "turn_start", uuid: generateUuid(vendor), messageType: "user" });
376
- var result = await prompt(currentMessage);
394
+ var result = await cancellation.wait(prompt(currentMessage));
377
395
  var closing = normalizer.closeOpenBlocks(state);
378
396
  for (var i = 0; i < closing.length; i++) pushEvent(closing[i]);
379
397
  if (isCancelled() || (result && result.stopReason === "cancelled")) {
@@ -395,6 +413,7 @@ function createAcpQueryHandle(acp, queryOpts) {
395
413
  : { yokeType: "error", text: message });
396
414
  }
397
415
  } finally {
416
+ await stopDelivery;
398
417
  acp.removeHandler(handlerEntry);
399
418
  state.done = true;
400
419
  endIterator();
@@ -448,36 +467,29 @@ function createAcpQueryHandle(acp, queryOpts) {
448
467
  }; });
449
468
  },
450
469
  abort: function() {
470
+ if (state.aborted || iteratorDone) return;
451
471
  state.aborted = true;
452
- driverRuntime.call(driver, "cancel", driverContext(), function() {
453
- if (state.sessionId && acp.started) acp.notify("session/cancel", { sessionId: state.sessionId });
454
- });
455
- endIterator();
456
- },
457
- close: function() {
458
- messageQueueEnded = true;
459
- if (messageWaiting) {
460
- var resolve = messageWaiting;
461
- messageWaiting = null;
462
- resolve(null);
463
- }
464
- endIterator();
472
+ cancellation.stop();
473
+ handle.endInput();
474
+ messageQueue = [];
475
+ pendingPermissions.forEach(function (cancel) { try { cancel(); } catch (e) {} });
476
+ stopDelivery = driverRuntime.callAsync(driver, "cancel", driverContext(), function() {
477
+ if (state.sessionId && (!acp.started || acp.notify("session/cancel", { sessionId: state.sessionId }) === false)) {
478
+ throw new Error("ACP transport is disconnected");
479
+ }
480
+ }).catch(function (error) {
481
+ pushEvent({ yokeType: "error", text: "Cancellation could not be delivered: " + error.message });
482
+ }).finally(endIterator);
483
+ return stopDelivery;
465
484
  },
485
+ close: function() { handle.endInput(); endIterator(); },
466
486
  endInput: function() {
467
487
  messageQueueEnded = true;
468
- if (messageWaiting) {
469
- var resolve = messageWaiting;
470
- messageWaiting = null;
471
- resolve(null);
472
- }
488
+ if (messageWaiting) { var resolve = messageWaiting; messageWaiting = null; resolve(null); }
473
489
  },
474
490
  };
475
491
 
476
- if (abortController && abortController.signal) {
477
- abortController.signal.addEventListener("abort", function() {
478
- if (!state.aborted) handle.abort();
479
- }, { once: true });
480
- }
492
+ removeAbort = queryAbort.bindAbort(abortController, function () { handle.abort(); });
481
493
  return handle;
482
494
  }
483
495
 
@@ -1,3 +1,4 @@
1
+ var bindAbort = require("../query-abort").bindAbort;
1
2
  var childProcess = require("child_process");
2
3
  var readline = require("readline");
3
4
  var skillDiscovery = require("../skill-discovery");
@@ -75,6 +76,9 @@ function toolName(name) {
75
76
 
76
77
  function createAntigravityQueryHandle(binaryPath, queryOpts, onFinished) {
77
78
  var processHandle = null;
79
+ var removeAbort = function () {};
80
+ var killTimer = null;
81
+ var processExited = false;
78
82
  var outputReader = null;
79
83
  var events = [];
80
84
  var eventWaiter = null;
@@ -115,6 +119,7 @@ function createAntigravityQueryHandle(binaryPath, queryOpts, onFinished) {
115
119
  function endEvents() {
116
120
  if (ended) return;
117
121
  ended = true;
122
+ removeAbort();
118
123
  if (eventWaiter) {
119
124
  var resolve = eventWaiter;
120
125
  eventWaiter = null;
@@ -252,6 +257,8 @@ function createAntigravityQueryHandle(binaryPath, queryOpts, onFinished) {
252
257
  endEvents();
253
258
  });
254
259
  processHandle.on("exit", function(code, signal) {
260
+ processExited = true;
261
+ if (killTimer) clearTimeout(killTimer);
255
262
  if (!ended && activeTurn) {
256
263
  var message = stderr.trim() || "Antigravity CLI exited before completing the turn";
257
264
  pushEvent(isAuthError(message)
@@ -309,8 +316,24 @@ function createAntigravityQueryHandle(binaryPath, queryOpts, onFinished) {
309
316
  },
310
317
  abort: function() {
311
318
  if (ended) return;
319
+ inputEnded = true;
320
+ messages = [];
312
321
  pushEvent({ yokeType: "interrupted" });
313
- if (processHandle) processHandle.kill("SIGINT");
322
+ if (processHandle && !processExited) {
323
+ try { processHandle.kill("SIGINT"); } catch (error) {
324
+ pushEvent({ yokeType: "error", text: "Could not interrupt Antigravity: " + error.message });
325
+ }
326
+ if (!processExited) {
327
+ killTimer = setTimeout(function () {
328
+ if (!processExited) {
329
+ try { processHandle.kill("SIGKILL"); } catch (error) {
330
+ console.error("[yoke/antigravity] Could not terminate stopped query:", error.message);
331
+ }
332
+ }
333
+ }, 5000);
334
+ killTimer.unref();
335
+ }
336
+ }
314
337
  endEvents();
315
338
  },
316
339
  close: function() {
@@ -319,6 +342,7 @@ function createAntigravityQueryHandle(binaryPath, queryOpts, onFinished) {
319
342
  endEvents();
320
343
  },
321
344
  };
345
+ removeAbort = bindAbort(queryOpts.abortController, function () { handle.abort(); });
322
346
  return handle;
323
347
  }
324
348
 
@@ -4,6 +4,7 @@
4
4
  // This is the ONLY file (besides claude-worker.js) that imports the SDK.
5
5
  // Also manages worker processes for OS-level user isolation.
6
6
 
7
+ var bindQueryAbort = require("../query-abort").bindAbort;
7
8
  var path = require("path");
8
9
  var fs = require("fs");
9
10
  var os = require("os");
@@ -576,6 +577,7 @@ function normalizeBackgroundTasks(tasks) {
576
577
  function createQueryHandle(rawQuery, messageQueue, abortController) {
577
578
  var correlation = claudeCorrelation.createCorrelation();
578
579
  var closed = false;
580
+ var removeAbort = function () {};
579
581
  var handle = {
580
582
  // Opaque adapter state (null for in-process queries)
581
583
  _adapterState: null,
@@ -586,11 +588,12 @@ function createQueryHandle(rawQuery, messageQueue, abortController) {
586
588
  return {
587
589
  next: function() {
588
590
  return rawIter.next().then(function(result) {
589
- if (result.done) closed = true;
591
+ if (result.done) { closed = true; removeAbort(); }
590
592
  if (result.done) return result;
591
593
  return { value: correlation.result(result.value, flattenEvent(result.value)), done: false };
592
594
  }, function(err) {
593
595
  closed = true;
596
+ removeAbort();
594
597
  throw err;
595
598
  });
596
599
  },
@@ -684,6 +687,7 @@ function createQueryHandle(rawQuery, messageQueue, abortController) {
684
687
 
685
688
  abort: function() {
686
689
  closed = true;
690
+ try { messageQueue.end(); } catch (e) {}
687
691
  if (abortController) {
688
692
  try { abortController.abort(); } catch (e) {}
689
693
  }
@@ -691,6 +695,7 @@ function createQueryHandle(rawQuery, messageQueue, abortController) {
691
695
 
692
696
  close: function() {
693
697
  closed = true;
698
+ removeAbort();
694
699
  try { messageQueue.end(); } catch (e) {}
695
700
  if (rawQuery && typeof rawQuery.close === "function") {
696
701
  try { rawQuery.close(); } catch (e) {}
@@ -712,6 +717,10 @@ function createQueryHandle(rawQuery, messageQueue, abortController) {
712
717
  },
713
718
  };
714
719
 
720
+ removeAbort = bindQueryAbort(abortController, function () {
721
+ closed = true;
722
+ try { messageQueue.end(); } catch (e) {}
723
+ });
715
724
  return handle;
716
725
  }
717
726
 
@@ -1043,6 +1052,7 @@ function serializeWorkerValue(value, seen) {
1043
1052
  // query identically to an in-process query.
1044
1053
 
1045
1054
  function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool, onUserDialog, onUserInputRequest, onModelSwitch, preToolUsePolicy) {
1055
+ var interactionAbort = new AbortController();
1046
1056
  var contextRequests = claudeContextUsage.createRequests(worker);
1047
1057
  var correlation = claudeCorrelation.createCorrelation();
1048
1058
  // Async iterable state
@@ -1085,6 +1095,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool,
1085
1095
  }
1086
1096
 
1087
1097
  function endIter() {
1098
+ interactionAbort.abort();
1088
1099
  contextRequests.close();
1089
1100
  if (iterEnded) return;
1090
1101
  iterEnded = true;
@@ -1096,6 +1107,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool,
1096
1107
  }
1097
1108
 
1098
1109
  function errorIter(err) {
1110
+ interactionAbort.abort();
1099
1111
  contextRequests.close();
1100
1112
  if (iterEnded) return;
1101
1113
  iterEnded = true;
@@ -1122,8 +1134,9 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool,
1122
1134
  decisionReason: msg.decisionReason,
1123
1135
  defaultToNo: msg.defaultToNo === true,
1124
1136
  suppressAlwaysAllowRule: msg.suppressAlwaysAllowRule === true,
1125
- signal: { addEventListener: function() {} },
1137
+ signal: interactionAbort.signal,
1126
1138
  }).then(function(result) {
1139
+ if (interactionAbort.signal.aborted) result = { behavior: "deny", message: "Query cancelled" };
1127
1140
  worker.send({ type: "permission_response", requestId: msg.requestId, result: result });
1128
1141
  }).catch(function(e) {
1129
1142
  console.error("[yoke/claude] permission_response send failed:", e.message || e);
@@ -1149,8 +1162,9 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool,
1149
1162
  if (onUserInputRequest) {
1150
1163
  claudeUserInput.ask(onUserInputRequest, msg.input, {
1151
1164
  toolUseID: msg.toolUseId,
1152
- signal: { addEventListener: function() {} },
1165
+ signal: interactionAbort.signal,
1153
1166
  }).then(function(result) {
1167
+ if (interactionAbort.signal.aborted) return;
1154
1168
  worker.send({ type: "ask_user_response", toolUseId: msg.toolUseId, result: result });
1155
1169
  }).catch(function(e) {
1156
1170
  console.error("[yoke/claude] ask_user_response send failed:", e.message || e);
@@ -1171,12 +1185,13 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool,
1171
1185
  var workerElicitation = onUserInputRequest
1172
1186
  ? claudeUserInput.elicitation(onUserInputRequest, workerElicitationRequest, {
1173
1187
  requestId: msg.requestId,
1174
- signal: { addEventListener: function() {} },
1188
+ signal: interactionAbort.signal,
1175
1189
  })
1176
1190
  : onElicitation(workerElicitationRequest, {
1177
- signal: { addEventListener: function() {} },
1191
+ signal: interactionAbort.signal,
1178
1192
  });
1179
1193
  workerElicitation.then(function(result) {
1194
+ if (interactionAbort.signal.aborted) return;
1180
1195
  worker.send({ type: "elicitation_response", requestId: msg.requestId, result: result });
1181
1196
  }).catch(function(e) {
1182
1197
  console.error("[yoke/claude] elicitation_response send failed:", e.message || e);
@@ -1191,8 +1206,9 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool,
1191
1206
  payload: msg.payload || {},
1192
1207
  toolUseID: msg.toolUseId || undefined,
1193
1208
  }, {
1194
- signal: { addEventListener: function() {} },
1209
+ signal: interactionAbort.signal,
1195
1210
  }).then(function(result) {
1211
+ if (interactionAbort.signal.aborted) return;
1196
1212
  worker.send({ type: "user_dialog_response", requestId: msg.requestId, result: result });
1197
1213
  }).catch(function(e) {
1198
1214
  console.error("[yoke/claude] user_dialog_response send failed:", e.message || e);
@@ -1327,7 +1343,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool,
1327
1343
  getSubmittedMessageCount: correlation.submitted,
1328
1344
 
1329
1345
  pushMessage: function(text, images) {
1330
- if (iterEnded || (worker.process && (worker.process.killed || worker.process.exitCode != null))) return false;
1346
+ if (iterEnded || worker._abortSent || (worker.process && (worker.process.killed || worker.process.exitCode != null))) return false;
1331
1347
  var content = [];
1332
1348
  if (images && images.length > 0) {
1333
1349
  for (var i = 0; i < images.length; i++) {
@@ -1417,8 +1433,10 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool,
1417
1433
  },
1418
1434
 
1419
1435
  abort: function() {
1436
+ if (iterEnded || worker._abortSent) return;
1420
1437
  console.log("[yoke/claude] ABORT sent to worker pid=" + (worker.process ? worker.process.pid : "?"));
1421
1438
  worker._abortSent = true;
1439
+ interactionAbort.abort();
1422
1440
  try { worker.send({ type: "abort" }); } catch (e) {}
1423
1441
  // If the worker doesn't finish within 5s (e.g. subagent stuck), force-kill it.
1424
1442
  // The worker exit handler will dispatch a fallback query_error and send done.
@@ -3,6 +3,8 @@
3
3
  // Implements the YOKE interface using codex app-server protocol.
4
4
  // Bidirectional JSON-RPC over stdin/stdout enables interactive approval flows.
5
5
 
6
+ var createTurnEvents = require("../codex-turn-events").createTurnEvents;
7
+ var bindQueryAbort = require("../query-abort").bindAbort;
6
8
  var path = require("path");
7
9
  var fs = require("fs");
8
10
  var { CodexAppServer } = require("../codex-app-server");
@@ -11,6 +13,7 @@ var skillDiscovery = require("../skill-discovery");
11
13
  var { resolveOsUserInfo } = require("../../os-users");
12
14
  var backgroundTasks = require("../codex-background-tasks");
13
15
  var codexUserInput = require("../codex-user-input");
16
+ var createTurnStop = require("../codex-turn-stop").createTurnStop;
14
17
 
15
18
  // --- Reasoning defaults ---
16
19
  // Reasoning summaries are the only readable thinking channel Codex offers:
@@ -812,6 +815,7 @@ function createEventState(model) {
812
815
 
813
816
  function createCodexQueryHandle(appServer, queryOpts) {
814
817
  var abortController = queryOpts.abortController;
818
+ var removeAbort = function () {};
815
819
  var promptParts = [queryOpts.systemPrompt, queryOpts.appendSystemPrompt].filter(function (part) { return !!part; });
816
820
  var systemPrompt = promptParts.join("\n\n");
817
821
  var canUseTool = queryOpts.canUseTool || null;
@@ -826,6 +830,11 @@ function createCodexQueryHandle(appServer, queryOpts) {
826
830
  }
827
831
 
828
832
  var state = createEventState(queryOpts.model);
833
+ var turnEvents = createTurnEvents(handleServerEvent);
834
+ var turnStop = createTurnStop(appServer, function (error) {
835
+ console.error("[yoke/codex] turn interrupt failed:", error.message || error);
836
+ pushEvent({ yokeType: "error", text: "Codex cancellation could not be confirmed. The remote turn may still be running." });
837
+ });
829
838
 
830
839
  // Internal event buffer for async iterator
831
840
  var eventBuffer = [];
@@ -864,6 +873,7 @@ function createCodexQueryHandle(appServer, queryOpts) {
864
873
  }
865
874
 
866
875
  function endIterator() {
876
+ removeAbort();
867
877
  iteratorDone = true;
868
878
  if (eventWaiting) {
869
879
  var resolve = eventWaiting;
@@ -912,6 +922,13 @@ function createCodexQueryHandle(appServer, queryOpts) {
912
922
  // Ignore events from other threads (app-server is shared across sessions)
913
923
  if (params.threadId && state.threadId && params.threadId !== state.threadId) return;
914
924
 
925
+ if (!turnEvents.accept(msg)) return;
926
+
927
+ // Capture cancellation identity even if Stop arrived before turn/start replied.
928
+ var eventTurnId = params.turnId || params.turn_id || (params.turn && params.turn.id);
929
+ if (method === "turn/started") turnStop.started(eventTurnId);
930
+ if (method === "turn/completed" || method === "turn/failed") turnStop.completed(eventTurnId);
931
+
915
932
  // After abort, only let turn-ending events through
916
933
  if (isCancelled() && method !== "turn/completed" && method !== "turn/failed" && method !== "serverRequest/resolved" && method !== "thread/status/changed") return;
917
934
 
@@ -1089,6 +1106,7 @@ function createCodexQueryHandle(appServer, queryOpts) {
1089
1106
 
1090
1107
  // Resolve turn promise when turn ends
1091
1108
  if (method === "turn/completed" || method === "turn/failed") {
1109
+ turnEvents.finish();
1092
1110
  if (turnResolve) {
1093
1111
  var resolve = turnResolve;
1094
1112
  turnResolve = null;
@@ -1204,6 +1222,7 @@ function createCodexQueryHandle(appServer, queryOpts) {
1204
1222
  while (!isCancelled()) {
1205
1223
  // Reset per-turn state
1206
1224
  state.turnStarted = false;
1225
+ state.currentTurnId = null;
1207
1226
  state.textBlocks = {};
1208
1227
  state.textLengths = {};
1209
1228
  state.thinkingBlocks = {};
@@ -1243,11 +1262,22 @@ function createCodexQueryHandle(appServer, queryOpts) {
1243
1262
  input = input.concat(skillItemsToInject);
1244
1263
  }
1245
1264
 
1246
- await appServer.send("turn/start", {
1247
- threadId: state.threadId,
1248
- input: input,
1249
- model: state.model,
1250
- }, 60000);
1265
+ turnStop.begin(state.threadId);
1266
+ turnEvents.begin();
1267
+ try {
1268
+ var turnResult = await appServer.send("turn/start", {
1269
+ threadId: state.threadId,
1270
+ input: input,
1271
+ model: state.model,
1272
+ }, 60000);
1273
+ var startedTurnId = turnResult && turnResult.turn && turnResult.turn.id;
1274
+ turnStop.startSettled(startedTurnId);
1275
+ turnEvents.ready(startedTurnId);
1276
+ } catch (startError) {
1277
+ turnEvents.finish();
1278
+ turnStop.startSettled(null);
1279
+ throw startError;
1280
+ }
1251
1281
 
1252
1282
  // Wait for turn to complete
1253
1283
  await turnPromise;
@@ -1348,19 +1378,23 @@ function createCodexQueryHandle(appServer, queryOpts) {
1348
1378
  abort: function() {
1349
1379
  console.log("[yoke/codex] handle.abort() called, threadId=" + state.threadId + " already aborted=" + state.aborted);
1350
1380
  state.aborted = true;
1351
- // Send turn/interrupt to stop the server-side turn
1352
- if (state.threadId && appServer.started) {
1353
- appServer.send("turn/interrupt", { threadId: state.threadId }, 5000).catch(function() {});
1354
- }
1355
- // End iterator immediately. sdk-bridge's post-loop code checks
1356
- // session.taskStopRequested and sends the interrupted message + done.
1357
- // This matches Claude's abort pattern.
1358
- if (turnResolve) {
1359
- var resolve = turnResolve;
1360
- turnResolve = null;
1361
- resolve();
1362
- }
1363
- endIterator();
1381
+ messageQueueEnded = true;
1382
+ messageQueue = [];
1383
+ if (messageWaiting) {
1384
+ var releaseMessage = messageWaiting;
1385
+ messageWaiting = null;
1386
+ releaseMessage(null);
1387
+ }
1388
+ // Keep the iterator alive until the exact interrupt settles so failures
1389
+ // reach the transcript instead of being silently reported as a stop.
1390
+ return turnStop.abort().then(function () {
1391
+ if (turnResolve) {
1392
+ var resolve = turnResolve;
1393
+ turnResolve = null;
1394
+ resolve();
1395
+ }
1396
+ endIterator();
1397
+ });
1364
1398
  },
1365
1399
 
1366
1400
  close: function() {
@@ -1383,12 +1417,9 @@ function createCodexQueryHandle(appServer, queryOpts) {
1383
1417
  },
1384
1418
  };
1385
1419
 
1386
- // Listen for external abort (sdk-bridge's stopTask calls session.abortController.abort())
1387
- if (abortController && abortController.signal) {
1388
- abortController.signal.addEventListener("abort", function() {
1389
- if (!state.aborted) handle.abort();
1390
- }, { once: true });
1391
- }
1420
+ removeAbort = bindQueryAbort(abortController, function () {
1421
+ if (!state.aborted) handle.abort();
1422
+ });
1392
1423
 
1393
1424
  return handle;
1394
1425
  }
@@ -12,6 +12,7 @@
12
12
  // session/request_permission requests for tool approvals.
13
13
  // session/cancel (notif) -> interrupt the active turn
14
14
 
15
+ var queryAbort = require("../query-abort");
15
16
  var { execFile } = require("child_process");
16
17
  var { KiroAcpServer, findKiroPath } = require("../kiro-acp-server");
17
18
  var INITIALIZE_TIMEOUT_MS = require("../interface").INITIALIZE_TIMEOUT_MS;
@@ -398,6 +399,12 @@ function createEventState(opts) {
398
399
 
399
400
  function createKiroQueryHandle(acp, queryOpts) {
400
401
  var abortController = queryOpts.abortController;
402
+ var cancellation = queryAbort.createAbortScope();
403
+ var removeAbort = function () {};
404
+ function send(method, params, timeout) {
405
+ cancellation.check();
406
+ return cancellation.wait(acp.send(method, params, timeout, cancellation.signal));
407
+ }
401
408
  var promptParts = [queryOpts.systemPrompt, queryOpts.appendSystemPrompt].filter(function (part) { return !!part; });
402
409
  var systemPrompt = promptParts.join("\n\n");
403
410
  var canUseTool = queryOpts.canUseTool || null;
@@ -436,6 +443,7 @@ function createKiroQueryHandle(acp, queryOpts) {
436
443
 
437
444
  function endIterator() {
438
445
  iteratorDone = true;
446
+ removeAbort();
439
447
  if (eventWaiting) {
440
448
  var resolve = eventWaiting;
441
449
  eventWaiting = null;
@@ -506,9 +514,10 @@ function createKiroQueryHandle(acp, queryOpts) {
506
514
  var meta = (tc.toolCallId && state.toolMeta[tc.toolCallId]) || {};
507
515
  var toolName = toolNameForKind(tc.kind || meta.kind, tc.title || meta.title);
508
516
  var toolInput = tc.rawInput || meta.rawInput || { title: tc.title || meta.title };
509
- canUseTool(toolName, toolInput, {}).then(function(decision) {
510
- acp.respond(msg.id, { outcome: { outcome: "selected", optionId: isApproved(decision) ? allowId : rejectId } });
517
+ cancellation.wait(Promise.resolve().then(function () { cancellation.check(); return canUseTool(toolName, toolInput, { signal: cancellation.signal }); })).then(function(decision) {
518
+ acp.respond(msg.id, isCancelled() ? { outcome: { outcome: "cancelled" } } : { outcome: { outcome: "selected", optionId: isApproved(decision) ? allowId : rejectId } });
511
519
  }).catch(function(err) {
520
+ if (isCancelled()) { acp.respond(msg.id, { outcome: { outcome: "cancelled" } }); return; }
512
521
  console.error("[yoke/kiro] canUseTool error:", err.message);
513
522
  acp.respond(msg.id, { outcome: { outcome: "selected", optionId: rejectId } });
514
523
  });
@@ -592,13 +601,13 @@ function createKiroQueryHandle(acp, queryOpts) {
592
601
  function setSessionModel(model) {
593
602
  if (!state.sessionId || !acp.started) return Promise.resolve();
594
603
  if (state.engine === "v3") {
595
- return acp.send("session/set_config_option", {
604
+ return send("session/set_config_option", {
596
605
  sessionId: state.sessionId,
597
606
  configId: "model",
598
607
  value: model,
599
608
  }, 15000);
600
609
  }
601
- return acp.send("session/set_model", { sessionId: state.sessionId, modelId: model }, 15000);
610
+ return send("session/set_model", { sessionId: state.sessionId, modelId: model }, 15000);
602
611
  }
603
612
 
604
613
  // --- Main query loop ---
@@ -638,12 +647,13 @@ function createKiroQueryHandle(acp, queryOpts) {
638
647
  handlerEntry.sessionId = state.sessionId;
639
648
  state.loadingSession = true;
640
649
  try {
641
- await acp.send("session/load", {
650
+ await send("session/load", {
642
651
  sessionId: state.sessionId,
643
652
  cwd: queryOpts.cwd,
644
653
  mcpServers: queryOpts.mcpServers || [],
645
654
  }, 60000);
646
655
  } catch (e) {
656
+ if (isCancelled()) throw e;
647
657
  // If load fails (unknown session), fall back to a fresh session.
648
658
  console.warn("[yoke/kiro] session/load failed, starting fresh:", e.message);
649
659
  state.sessionId = null;
@@ -653,7 +663,7 @@ function createKiroQueryHandle(acp, queryOpts) {
653
663
  }
654
664
  }
655
665
  if (!state.sessionId) {
656
- var newResult = await acp.send("session/new", {
666
+ var newResult = await send("session/new", {
657
667
  cwd: queryOpts.cwd,
658
668
  mcpServers: queryOpts.mcpServers || [],
659
669
  }, 60000);
@@ -664,7 +674,7 @@ function createKiroQueryHandle(acp, queryOpts) {
664
674
  // Kiro v3 defaults to autopilot, which bypasses permission requests.
665
675
  // Supervised mode is mandatory so every tool call reaches canUseTool.
666
676
  if (state.engine === "v3") {
667
- await acp.send("session/set_config_option", {
677
+ await send("session/set_config_option", {
668
678
  sessionId: state.sessionId,
669
679
  configId: "autopilot",
670
680
  value: "off",
@@ -676,7 +686,7 @@ function createKiroQueryHandle(acp, queryOpts) {
676
686
  await setSessionModel(queryOpts.model).catch(function() {});
677
687
  }
678
688
  if (queryOpts.mode) {
679
- await acp.send("session/set_mode", { sessionId: state.sessionId, modeId: queryOpts.mode }, 15000).catch(function() {});
689
+ await send("session/set_mode", { sessionId: state.sessionId, modeId: queryOpts.mode }, 15000).catch(function() {});
680
690
  }
681
691
 
682
692
  while (!isCancelled()) {
@@ -690,7 +700,7 @@ function createKiroQueryHandle(acp, queryOpts) {
690
700
  // Emit turn_start so the UI records a user turn boundary.
691
701
  pushEvent({ yokeType: "turn_start", uuid: generateUuid(), messageType: "user" });
692
702
 
693
- var promptResult = await acp.send("session/prompt", {
703
+ var promptResult = await send("session/prompt", {
694
704
  sessionId: state.sessionId,
695
705
  prompt: input,
696
706
  }, 30 * 60 * 1000);
@@ -779,11 +789,17 @@ function createKiroQueryHandle(acp, queryOpts) {
779
789
 
780
790
  abort: function() {
781
791
  console.log("[yoke/kiro] handle.abort() sessionId=" + state.sessionId + " already=" + state.aborted);
792
+ if (state.aborted || iteratorDone) return;
782
793
  state.aborted = true;
783
- if (state.sessionId && acp.started) {
784
- // ACP cancellation is a notification; the in-flight session/prompt will
785
- // resolve with stopReason "cancelled".
786
- acp.notify("session/cancel", { sessionId: state.sessionId });
794
+ cancellation.stop();
795
+ handle.endInput();
796
+ messageQueue = [];
797
+ try {
798
+ if (state.sessionId && (!acp.started || acp.notify("session/cancel", { sessionId: state.sessionId }) === false)) {
799
+ throw new Error("ACP transport is disconnected");
800
+ }
801
+ } catch (error) {
802
+ pushEvent({ yokeType: "error", text: "Kiro cancellation could not be delivered: " + error.message });
787
803
  }
788
804
  endIterator();
789
805
  },
@@ -808,12 +824,7 @@ function createKiroQueryHandle(acp, queryOpts) {
808
824
  },
809
825
  };
810
826
 
811
- if (abortController && abortController.signal) {
812
- abortController.signal.addEventListener("abort", function() {
813
- if (!state.aborted) handle.abort();
814
- }, { once: true });
815
- }
816
-
827
+ removeAbort = queryAbort.bindAbort(abortController, function () { handle.abort(); });
817
828
  return handle;
818
829
  }
819
830
 
@@ -0,0 +1,32 @@
1
+ // A resumed thread can still deliver notifications from its previous turn.
2
+ // Only turn/start's response authorizes tagged output for this query's turn.
3
+ function createTurnEvents(deliver) {
4
+ var active = false;
5
+ var starting = false;
6
+ var turnId = null;
7
+ var pending = [];
8
+ function idOf(message) {
9
+ var params = message.params || {};
10
+ return params.turnId || params.turn_id || (params.turn && params.turn.id) || null;
11
+ }
12
+ return {
13
+ begin: function () { active = true; starting = true; turnId = null; pending = []; },
14
+ accept: function (message) {
15
+ var id = idOf(message);
16
+ var scoped = id || /^(turn\/|item\/)/.test(message.method || '');
17
+ if (!scoped) return true;
18
+ if (!active) return false;
19
+ if (starting && id) { pending.push(message); return false; }
20
+ return !id || !turnId || id === turnId;
21
+ },
22
+ ready: function (id) {
23
+ turnId = id;
24
+ starting = false;
25
+ var buffered = pending;
26
+ pending = [];
27
+ buffered.forEach(function (message) { deliver(message); });
28
+ },
29
+ finish: function () { active = false; starting = false; pending = []; },
30
+ };
31
+ }
32
+ module.exports = { createTurnEvents: createTurnEvents };
@@ -0,0 +1,59 @@
1
+ // Stop only the turn owned by this query, including an in-flight turn/start.
2
+ function createTurnStop(server, onError) {
3
+ var turnId = null;
4
+ var threadId = null;
5
+ var starting = false;
6
+ var completed = false;
7
+ var stopped = false;
8
+ var sent = false;
9
+ var resolveStop = null;
10
+ var stopPromise = null;
11
+
12
+ function settle() {
13
+ if (resolveStop) { var resolve = resolveStop; resolveStop = null; resolve(); }
14
+ }
15
+
16
+ function interrupt() {
17
+ if (!stopped || sent) return;
18
+ if (!turnId) { if (!starting) settle(); return; }
19
+ sent = true;
20
+ var target = { threadId: threadId, turnId: turnId };
21
+ Promise.resolve().then(function () {
22
+ if (!server.started) throw new Error('Codex is disconnected; cancellation could not be confirmed.');
23
+ return server.send('turn/interrupt', target, 5000);
24
+ }).catch(function (error) {
25
+ onError(error);
26
+ }).finally(settle);
27
+ }
28
+
29
+ return {
30
+ begin: function (id) { threadId = id; turnId = null; starting = true; completed = false; sent = false; },
31
+ started: function (id) {
32
+ if (!id || turnId || completed) return;
33
+ turnId = id;
34
+ starting = false;
35
+ interrupt();
36
+ },
37
+ startSettled: function (id) {
38
+ starting = false;
39
+ if (id && !turnId && !completed) turnId = id;
40
+ interrupt();
41
+ },
42
+ completed: function (id) {
43
+ if (id && turnId && id !== turnId) return;
44
+ turnId = null;
45
+ completed = true;
46
+ starting = false;
47
+ if (!sent) settle();
48
+ },
49
+ abort: function () {
50
+ if (stopPromise) return stopPromise;
51
+ stopped = true;
52
+ stopPromise = new Promise(function (resolve) { resolveStop = resolve; });
53
+ interrupt();
54
+ return stopPromise;
55
+ },
56
+ };
57
+ }
58
+
59
+ module.exports = { createTurnStop: createTurnStop };
@@ -0,0 +1,40 @@
1
+ // Query-local cancellation. Never terminates a shared provider process.
2
+ function bindAbort(controller, abort) {
3
+ var signal = controller && controller.signal;
4
+ if (!signal) return function () {};
5
+ if (signal.aborted) { abort(); return function () {}; }
6
+ signal.addEventListener('abort', abort, { once: true });
7
+ return function () { signal.removeEventListener('abort', abort); };
8
+ }
9
+
10
+ function createAbortScope() {
11
+ var stopped = false;
12
+ var controller = new AbortController();
13
+ var waiting = new Set();
14
+ function error() { var err = new Error('Query cancelled'); err.name = 'AbortError'; return err; }
15
+ return {
16
+ signal: controller.signal,
17
+ stopped: function () { return stopped; },
18
+ wait: function (promise) {
19
+ return new Promise(function (resolve, reject) {
20
+ function cancel() { reject(error()); }
21
+ if (stopped) cancel();
22
+ else waiting.add(cancel);
23
+ Promise.resolve(promise).then(function (value) {
24
+ waiting.delete(cancel);
25
+ if (stopped) cancel(); else resolve(value);
26
+ }, function (err) { waiting.delete(cancel); reject(err); });
27
+ });
28
+ },
29
+ stop: function () {
30
+ if (stopped) return;
31
+ stopped = true;
32
+ controller.abort();
33
+ waiting.forEach(function (cancel) { cancel(); });
34
+ waiting.clear();
35
+ },
36
+ check: function () { if (stopped) throw error(); },
37
+ };
38
+ }
39
+
40
+ module.exports = { bindAbort: bindAbort, createAbortScope: createAbortScope };
@@ -0,0 +1,11 @@
1
+ // Required decisions use Clay's blocking question lifecycle in every Codex mode.
2
+ function codexQuestionGuidance() {
3
+ return [
4
+ "When clarification materially changes task scope, targets, or intended behavior, use Clay's ask_user_questions tool (or its available MCP-qualified equivalent).",
5
+ "Present the question as a structured input card, not a question followed by bullet choices in ordinary chat. Await the tool result before work that depends on the answer.",
6
+ "Call the blocking question tool by itself; do not batch dependent actions alongside it or detach it into background work. request_user_input may be unavailable outside Plan mode, so use ask_user_questions in that case.",
7
+ "A cancelled, skipped, empty, or failed question is not an answer or approval. Do not infer a choice; leave dependent work pending and explain what answer is needed.",
8
+ "Do not ask again when the user has already answered or authorized the work. This question tool does not replace tool-permission enforcement."
9
+ ].join(" ");
10
+ }
11
+ module.exports = { codexQuestionGuidance: codexQuestionGuidance };
@@ -219,7 +219,7 @@ function fallbackToolDefs(handler, options) {
219
219
  queryBound: true,
220
220
  description: initialLimit === 1
221
221
  ? "Ask the user exactly one structured question, wait for the answer, then call again if another question is needed. Use zero options for freeform or 2-6 options for choices; never one option."
222
- : "Ask the user 1-3 structured questions. Use zero options for freeform or 2-6 options for choices; never one option.",
222
+ : "Ask the user 1-3 structured questions and wait for their answers before continuing dependent work. Use this for scope or target decisions; do not substitute prose questions or asynchronous notifications. A cancelled or skipped question is not an answer. Use zero options for freeform or 2-6 options for choices; never one option.",
223
223
  inputSchema: buildQuestionShape(initialLimit),
224
224
  handler: function (input) {
225
225
  if (questionLimit(options) === 1 && input && Array.isArray(input.questions) && input.questions.length !== 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.7.0-beta.2",
3
+ "version": "4.7.0-beta.3",
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"