clay-server 4.1.0-beta.1 → 4.1.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.
@@ -0,0 +1,23 @@
1
+ function canonicalize(value) {
2
+ if (Array.isArray(value)) return value.map(canonicalize);
3
+ if (!value || typeof value !== "object") return value;
4
+ var keys = Object.keys(value).sort();
5
+ var result = {};
6
+ for (var i = 0; i < keys.length; i++) result[keys[i]] = canonicalize(value[keys[i]]);
7
+ return result;
8
+ }
9
+
10
+ function sameAskUserRequest(event, id, input) {
11
+ return !!(event && event.type === "tool_executing" && event.name === "AskUserQuestion"
12
+ && event.id === id && JSON.stringify(canonicalize(event.input || {})) === JSON.stringify(canonicalize(input || {})));
13
+ }
14
+
15
+ function hasRecordedAskUserRequest(history, id, input) {
16
+ if (!Array.isArray(history) || !id) return false;
17
+ for (var i = history.length - 1; i >= 0; i--) {
18
+ if (sameAskUserRequest(history[i], id, input)) return true;
19
+ }
20
+ return false;
21
+ }
22
+
23
+ module.exports = { canonicalize: canonicalize, sameAskUserRequest: sameAskUserRequest, hasRecordedAskUserRequest: hasRecordedAskUserRequest };
@@ -1,4 +1,5 @@
1
1
  var userInput = require("./yoke/user-input");
2
+ var askUserIdentity = require("./ask-user-request-identity");
2
3
 
3
4
  function attachAskUser(ctx) {
4
5
  function isFirstDebateQuestion(session) {
@@ -41,7 +42,9 @@ function attachAskUser(ctx) {
41
42
  if (boundSession.pendingAskUser && boundSession.pendingAskUser[request.id] === pending) delete boundSession.pendingAskUser[request.id];
42
43
  });
43
44
  }
44
- ctx.record(boundSession, { type: "tool_executing", id: request.id, name: "AskUserQuestion", input: input });
45
+ if (!askUserIdentity.hasRecordedAskUserRequest(boundSession.history, request.id, input)) {
46
+ ctx.record(boundSession, { type: "tool_executing", id: request.id, name: "AskUserQuestion", input: input });
47
+ }
45
48
  }
46
49
 
47
50
  function createHandler(boundSession) {
@@ -86,22 +86,47 @@ function attachPairLifecycle(ctx) {
86
86
  var store = ctx.splitStore;
87
87
  var turnControl = ctx.turnControl;
88
88
 
89
- // Per-Driver ledger of the Worker generations it has run, newest last.
90
- // Lives on the live Driver session object: it informs the Driver's next
91
- // choice within this session and is deliberately not persisted, because a
92
- // bounded observation of a session that no longer exists would only be
93
- // misleading after a restart.
94
89
  function ledgerFor(driver) {
95
90
  if (!Array.isArray(driver._workerGenerations)) driver._workerGenerations = [];
91
+ var group = store.groupForMember(driver.localId);
92
+ var worker = group && group.pair && sm.sessions.get(group.pair.workerId);
93
+ var generation = worker && (worker._pairGeneration || worker.sessionProvenance && worker.sessionProvenance.generation);
94
+ if (worker && Number.isInteger(generation) && generation > 0 && !findGeneration(driver, worker)) {
95
+ driver._workerGenerations.push({
96
+ generation: generation,
97
+ workerSessionId: worker.localId,
98
+ workerOriginId: worker.sessionOriginId || null,
99
+ vendor: worker.vendor || null,
100
+ model: worker.model || null,
101
+ effort: worker.effort || null,
102
+ startedAt: worker.sessionProvenance && worker.sessionProvenance.createdAt || Date.now(),
103
+ endedAt: null,
104
+ observed: null,
105
+ evaluation: null,
106
+ });
107
+ while (driver._workerGenerations.length > MAX_GENERATIONS) driver._workerGenerations.shift();
108
+ }
96
109
  return driver._workerGenerations;
97
110
  }
98
-
99
111
  function recordGenerationStart(driver, worker) {
100
112
  var ledger = ledgerFor(driver);
101
- var generation = ledger.length + 1;
113
+ var existing = findGeneration(driver, worker);
114
+ if (existing && !existing.endedAt) {
115
+ worker._pairGeneration = existing.generation;
116
+ return existing.generation;
117
+ }
118
+ var generation = 1;
119
+ for (var i = 0; i < ledger.length; i++) {
120
+ if (Number.isInteger(ledger[i].generation) && ledger[i].generation >= generation) generation = ledger[i].generation + 1;
121
+ }
122
+ if (worker.sessionProvenance && Number.isInteger(worker.sessionProvenance.generation) &&
123
+ worker.sessionProvenance.generation >= generation) {
124
+ generation = worker.sessionProvenance.generation;
125
+ }
102
126
  ledger.push({
103
127
  generation: generation,
104
128
  workerSessionId: worker.localId,
129
+ workerOriginId: worker.sessionOriginId || null,
105
130
  vendor: worker.vendor || null,
106
131
  model: worker.model || null,
107
132
  effort: worker.effort || null,
@@ -112,21 +137,38 @@ function attachPairLifecycle(ctx) {
112
137
  });
113
138
  while (ledger.length > MAX_GENERATIONS) ledger.shift();
114
139
  worker._pairGeneration = generation;
140
+ if (worker.sessionProvenance) worker.sessionProvenance.generation = generation;
141
+ if (typeof sm.saveSessionFile === "function") {
142
+ sm.saveSessionFile(driver);
143
+ sm.saveSessionFile(worker);
144
+ }
115
145
  return generation;
116
146
  }
117
-
118
- function findGeneration(driver, workerSessionId) {
119
- var ledger = ledgerFor(driver);
147
+ function findGeneration(driver, worker) {
148
+ var ledger = Array.isArray(driver._workerGenerations) ? driver._workerGenerations : [];
149
+ var originId = worker && worker.sessionOriginId || null;
120
150
  for (var i = ledger.length - 1; i >= 0; i--) {
121
- if (ledger[i].workerSessionId === workerSessionId) return ledger[i];
151
+ if (originId && ledger[i].workerOriginId === originId) {
152
+ ledger[i].workerSessionId = worker.localId;
153
+ return ledger[i];
154
+ }
122
155
  }
123
- return null;
156
+ if (!worker || !originId) return null;
157
+ var generation = worker._pairGeneration || worker.sessionProvenance && worker.sessionProvenance.generation;
158
+ if (!Number.isInteger(generation) || generation < 1) return null;
159
+ var candidates = [];
160
+ for (var j = 0; j < ledger.length; j++) {
161
+ if (!ledger[j].workerOriginId && ledger[j].generation === generation) candidates.push(ledger[j]);
162
+ }
163
+ if (candidates.length !== 1) return null;
164
+ candidates[0].workerOriginId = originId;
165
+ candidates[0].workerSessionId = worker.localId;
166
+ if (typeof sm.saveSessionFile === "function") sm.saveSessionFile(driver);
167
+ return candidates[0];
124
168
  }
125
-
126
- // Objective signals the server already has. Recorded when a generation ends
127
- // so the Driver's next decision can use them without a transcript.
128
169
  function closeGeneration(driver, worker) {
129
- var record = findGeneration(driver, worker.localId);
170
+ ledgerFor(driver);
171
+ var record = findGeneration(driver, worker);
130
172
  if (!record || record.endedAt) return record;
131
173
  var continuity = continuityStatus(worker);
132
174
  var context = contextStatus(worker);
@@ -138,10 +180,9 @@ function attachPairLifecycle(ctx) {
138
180
  usedTokens: context.current.usedTokens,
139
181
  usedRatio: context.current.usedRatio,
140
182
  };
183
+ if (typeof sm.saveSessionFile === "function") sm.saveSessionFile(driver);
141
184
  return record;
142
185
  }
143
-
144
- // The exact pair, with the Driver's structural capability re-checked on every call.
145
186
  function resolveDriverPair(caller) {
146
187
  if (!caller) throw new Error("pair lifecycle tools require a session-bound tool server");
147
188
  // Exact live object identity, before anything is read off the caller. A
@@ -169,8 +210,6 @@ function attachPairLifecycle(ctx) {
169
210
  return null;
170
211
  }
171
212
 
172
- // Bounded status for the reuse-vs-replace decision. Exact pair only; no
173
- // other user's data and no transcript.
174
213
  function partnerStatus(caller) {
175
214
  var resolved = resolveDriverPair(caller);
176
215
  var worker = resolved.worker;
@@ -229,15 +268,11 @@ function attachPairLifecycle(ctx) {
229
268
  };
230
269
  }
231
270
 
232
- // Transactional replacement. Rejects an active Worker unless interrupt is
233
- // explicitly true, cancels anything the old Worker was waiting on, dissolves
234
- // the exact pair without deleting history, and creates a fresh Worker.
235
- // Idempotent in the sense that it either completes or leaves the existing
236
- // pair untouched; it never half-dissolves.
237
271
  function replacePartner(args, caller) {
238
272
  var resolved = resolveDriverPair(caller);
239
273
  var group = resolved.group;
240
274
  var oldWorker = resolved.worker;
275
+ ledgerFor(caller);
241
276
  turnControl.assertWorkerAction(caller);
242
277
  var replacementEntry = replacementState.begin(caller, args.transactionId, oldWorker, replacementFingerprint(args, oldWorker));
243
278
  var replay = replacementState.replayValue(replacementEntry);
@@ -344,7 +379,10 @@ function attachPairLifecycle(ctx) {
344
379
  ". " + restoreNote + interruptNote);
345
380
  }
346
381
  var closed = closeGeneration(caller, oldWorker);
347
- if (closed && args.evaluation) applyEvaluation(closed, args.evaluation);
382
+ if (closed && args.evaluation) {
383
+ applyEvaluation(closed, args.evaluation);
384
+ if (typeof sm.saveSessionFile === "function") sm.saveSessionFile(caller);
385
+ }
348
386
  var generation = recordGenerationStart(caller, created.worker);
349
387
 
350
388
  var result = {
@@ -372,17 +410,15 @@ function attachPairLifecycle(ctx) {
372
410
  });
373
411
  }
374
412
 
375
- // Shape check only; writes nothing. Lets a caller that is about to destroy
376
- // something reject a malformed assessment first.
413
+ // No global or cross-user ranking is formed from Worker evaluations.
377
414
  function validateEvaluation(raw) {
378
- var input = raw && typeof raw === "object" ? raw : {};
415
+ var input = typeof raw === "string" ? { outcome: raw } : (raw && typeof raw === "object" ? raw : {});
379
416
  var outcome = typeof input.outcome === "string" ? input.outcome.trim().toLowerCase() : "";
380
417
  if (EVALUATION_OUTCOMES.indexOf(outcome) === -1) {
381
418
  throw new Error('evaluation outcome must be one of: ' + EVALUATION_OUTCOMES.join(", "));
382
419
  }
383
420
  return { outcome: outcome, note: clampText(input.note, MAX_NOTE_CHARS) };
384
421
  }
385
-
386
422
  function applyEvaluation(record, raw) {
387
423
  var clean = validateEvaluation(raw);
388
424
  record.evaluation = {
@@ -393,23 +429,20 @@ function attachPairLifecycle(ctx) {
393
429
  return record.evaluation;
394
430
  }
395
431
 
396
- // Attach a bounded assessment to one exact Worker generation. The Driver
397
- // supplies the judgement; the server supplies the objective observations and
398
- // refuses anything outside the enum. No global or cross-user ranking is
399
- // formed from this.
400
432
  function recordEvaluation(args, caller) {
401
433
  var resolved = resolveDriverPair(caller);
434
+ var ledger = ledgerFor(caller);
402
435
  var target = Number.isInteger(args.generation)
403
436
  ? (function () {
404
- var ledger = ledgerFor(caller);
405
437
  for (var i = 0; i < ledger.length; i++) {
406
438
  if (ledger[i].generation === args.generation) return ledger[i];
407
439
  }
408
440
  return null;
409
441
  })()
410
- : findGeneration(caller, resolved.worker.localId);
442
+ : findGeneration(caller, resolved.worker);
411
443
  if (!target) throw new Error("no such Split Worker generation for this Driver");
412
444
  var evaluation = applyEvaluation(target, args);
445
+ if (typeof sm.saveSessionFile === "function") sm.saveSessionFile(caller);
413
446
  return {
414
447
  status: "recorded",
415
448
  generation: target.generation,
@@ -420,11 +453,6 @@ function attachPairLifecycle(ctx) {
420
453
  };
421
454
  }
422
455
 
423
- // Tool handlers for the three lifecycle tools, with this module's own error
424
- // shaping. Kept here so the pair coordinator only wires names to handlers.
425
- // partnerStatus, or null when this session cannot legitimately ask for it.
426
- // Lets read_partner fold the capacity report in without duplicating the
427
- // guard chain or swallowing errors inline at the call site.
428
456
  function optionalStatus(boundSession) {
429
457
  try { return partnerStatus(boundSession); } catch (e) { return null; }
430
458
  }
@@ -52,14 +52,17 @@ function contextStatus(session) {
52
52
  if (input !== null) { cumulativeInput += input; observedInput = true; }
53
53
  if (output !== null) { cumulativeOutput += output; observedOutput = true; }
54
54
  results++;
55
- lastTask = { inputTokens: input, outputTokens: output, currentInputTokens: firstNumber(item.lastStreamInputTokens) };
55
+ lastTask = { inputTokens: input, outputTokens: output, currentInputTokens: null };
56
56
  var observedWindow = matchingWindow(item.modelUsage, session.model);
57
57
  if (observedWindow !== null) window = observedWindow;
58
58
  }
59
- if (used === null && lastTask && lastTask.currentInputTokens !== null) {
60
- used = lastTask.currentInputTokens;
61
- source = "last_stream_input";
59
+ if (used !== null && window !== null && used > window) {
60
+ used = null;
61
+ source = "unavailable";
62
62
  }
63
+ // Older result records contain lastStreamInputTokens, but that field was
64
+ // populated from adapter snapshots that could be cumulative or inflated by
65
+ // cache accounting. It is not safe evidence of current context occupancy.
63
66
  var ratio = used !== null && window !== null && window > 0 ? Math.min(1, Math.round((used / window) * 1000) / 1000) : null;
64
67
  return {
65
68
  current: { source: source, usedTokens: used, windowTokens: window, usedRatio: ratio,
@@ -6,6 +6,18 @@ var driverOrchestration = require("./session-driver-orchestration");
6
6
  var proposalControl = require("./worker-proposal-control");
7
7
  var runtimeCatalog = require("./worker-runtime-catalog");
8
8
  var MAX_SUMMARY_CHARS = 600, MAX_PLAN_CHARS = 6000, MAX_TASK_CHARS = 30000, MAX_RATIONALE_CHARS = 1000;
9
+ var EVALUATION_OUTCOMES = ["succeeded", "partial", "failed", "abandoned"];
10
+ function normalizeEvaluation(raw) {
11
+ if (raw == null) return null;
12
+ var input = typeof raw === "string" ? { outcome: raw } : raw;
13
+ if (!input || typeof input !== "object") return { error: "evaluation must be an outcome string or object" };
14
+ var outcome = typeof input.outcome === "string" ? input.outcome.trim().toLowerCase() : "";
15
+ if (EVALUATION_OUTCOMES.indexOf(outcome) === -1) {
16
+ return { error: "evaluation outcome must be one of: " + EVALUATION_OUTCOMES.join(", ") };
17
+ }
18
+ var note = typeof input.note === "string" ? input.note.replace(/\s+/g, " ").trim().slice(0, 400) : "";
19
+ return { value: { outcome: outcome, note: note } };
20
+ }
9
21
  function modelValue(entry) {
10
22
  if (typeof entry === "string") return entry;
11
23
  return entry && (entry.value || entry.id) || "";
@@ -227,6 +239,8 @@ function attachWorkerProposal(ctx) {
227
239
  if (!task) return toolResult({ error: "message is required so an accepted replacement delegates exactly once." });
228
240
  if (!rationale) return toolResult({ error: "recommendationRationale is required for the replacement audit trail." });
229
241
  if (task.length > MAX_TASK_CHARS) return toolResult({ error: "The Split Worker task is too long." });
242
+ var normalizedEvaluation = normalizeEvaluation(args.evaluation);
243
+ if (normalizedEvaluation && normalizedEvaluation.error) return toolResult({ error: normalizedEvaluation.error });
230
244
  await ensureModelCatalogs();
231
245
  var liveGroup = store.groupForMember(session.localId);
232
246
  if (!isLiveSession(session) || !driverEligibility.isEligibleDriverSession(session, sm) ||
@@ -261,7 +275,7 @@ function attachWorkerProposal(ctx) {
261
275
  sourceGroupId: sourceGroupId,
262
276
  sourceWorkerId: sourceWorkerId,
263
277
  interrupt: args.interrupt === true,
264
- evaluation: args.evaluation || null,
278
+ evaluation: normalizedEvaluation ? normalizedEvaluation.value : null,
265
279
  transactionId: "replace_" + crypto.randomUUID(),
266
280
  };
267
281
  if (superseded) updateProposal(session, superseded, { status: "superseded", supersededBy: proposal.proposalId });
package/lib/public/app.js CHANGED
@@ -287,6 +287,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
287
287
  slashCommands: [],
288
288
  processing: false,
289
289
  activeSessionId: null,
290
+ askUserRequests: {},
290
291
  activeBackgroundTasks: [],
291
292
  sessionVendorBound: false,
292
293
  cliSessionId: null,
@@ -0,0 +1,66 @@
1
+ import { store } from './store.js';
2
+
3
+ function scopeKey() {
4
+ var sessionId = store.get('paneSessionId') || store.get('activeSessionId') || "unknown";
5
+ var project = store.get('currentSlug') || "unknown-project";
6
+ var pane = store.get('paneMode') ? "pane" : "main";
7
+ return project + "\u0000" + pane + "\u0000" + String(sessionId);
8
+ }
9
+
10
+ function requestKey(toolId) {
11
+ return scopeKey() + "\u0000" + String(toolId);
12
+ }
13
+
14
+ function requests() {
15
+ return store.get('askUserRequests') || {};
16
+ }
17
+
18
+ export function getAskUserRequestKey(toolId) {
19
+ return requestKey(toolId);
20
+ }
21
+
22
+ export function rememberAskUserRequest(toolId) {
23
+ var key = requestKey(toolId);
24
+ var current = requests();
25
+ if (!current[key]) {
26
+ var next = Object.assign({}, current);
27
+ next[key] = { toolId: String(toolId), scope: scopeKey(), answered: false };
28
+ store.set({ askUserRequests: next });
29
+ }
30
+ return key;
31
+ }
32
+
33
+ export function hasAskUserRequest(toolId) {
34
+ return !!requests()[requestKey(toolId)];
35
+ }
36
+
37
+ export function markAskUserRequestAnswered(toolId) {
38
+ var key = requestKey(toolId);
39
+ var current = requests();
40
+ if (!current[key]) return;
41
+ var next = Object.assign({}, current);
42
+ next[key] = Object.assign({}, next[key], { answered: true });
43
+ store.set({ askUserRequests: next });
44
+ }
45
+
46
+ export function findAskUserCard(root, toolId) {
47
+ if (!root || !root.querySelectorAll) return null;
48
+ var key = requestKey(toolId);
49
+ var cards = root.querySelectorAll(".ask-user-container");
50
+ for (var i = 0; i < cards.length; i++) {
51
+ if (cards[i].dataset && cards[i].dataset.askUserRequestKey === key) return cards[i];
52
+ }
53
+ return null;
54
+ }
55
+
56
+ export function saveAskUserState() {
57
+ return Object.assign({}, requests());
58
+ }
59
+
60
+ export function restoreAskUserState(saved) {
61
+ store.set({ askUserRequests: Object.assign({}, saved || {}) });
62
+ }
63
+
64
+ export function resetAskUserState() {
65
+ store.set({ askUserRequests: {} });
66
+ }
@@ -8,6 +8,7 @@ import { getChatLayout } from './theme.js';
8
8
  import { store } from './store.js';
9
9
  import { VENDOR_NAMES } from './app-rendering.js';
10
10
  import { startThinkingSegment, appendThinkingText, stopThinkingSegment, resetThinkingTurn, clearThinkingState, saveThinkingState, restoreThinkingState, updateThinkingTokenEstimate } from './thinking-lifecycle.js';
11
+ import { findAskUserCard, getAskUserRequestKey, hasAskUserRequest, rememberAskUserRequest, markAskUserRequestAnswered, saveAskUserState, restoreAskUserState, resetAskUserState } from './ask-user-state.js';
11
12
 
12
13
  var ctx;
13
14
 
@@ -227,6 +228,10 @@ function shortPath(p) {
227
228
 
228
229
  // --- AskUserQuestion ---
229
230
  export function renderAskUserQuestion(toolId, input) {
231
+ var existingCard = findAskUserCard(ctx.messagesEl, toolId);
232
+ if (existingCard) return existingCard;
233
+ if (hasAskUserRequest(toolId)) return null;
234
+
230
235
  ctx.finalizeAssistantBlock();
231
236
  stopThinking();
232
237
  closeToolGroup();
@@ -237,6 +242,9 @@ export function renderAskUserQuestion(toolId, input) {
237
242
  var container = document.createElement("div");
238
243
  container.className = "ask-user-container";
239
244
  container.dataset.toolId = toolId;
245
+ var requestKey = getAskUserRequestKey(toolId);
246
+ container.dataset.askUserRequestKey = requestKey;
247
+ rememberAskUserRequest(toolId);
240
248
 
241
249
  // Mate DM: wrap in avatar + content layout (same as msg-assistant)
242
250
  var mateContentWrap = null;
@@ -372,6 +380,7 @@ export function renderAskUserQuestion(toolId, input) {
372
380
  skipBtn.addEventListener("click", function () {
373
381
  if (container.classList.contains("answered")) return;
374
382
  container.classList.add("answered");
383
+ markAskUserRequestAnswered(toolId);
375
384
  enableMainInput();
376
385
  if (ctx.ws && ctx.connected) {
377
386
  ctx.ws.send(JSON.stringify({ type: "stop" }));
@@ -415,7 +424,7 @@ export function enableMainInput() {
415
424
  }
416
425
 
417
426
  function submitAskUserAnswer(container, toolId, questions, answers, multiSelections) {
418
- if (container.classList.contains("answered")) return;
427
+ if (container.classList.contains("answered") || container.dataset.askUserSubmitted === "true") return;
419
428
 
420
429
  var result = {};
421
430
  for (var i = 0; i < questions.length; i++) {
@@ -430,6 +439,7 @@ function submitAskUserAnswer(container, toolId, questions, answers, multiSelecti
430
439
  if (Object.keys(result).length === 0) return;
431
440
 
432
441
  container.classList.add("answered");
442
+ container.dataset.askUserSubmitted = "true";
433
443
  enableMainInput();
434
444
  if (ctx.stopUrgentBlink) ctx.stopUrgentBlink();
435
445
 
@@ -471,9 +481,10 @@ function showAnswerSummary(container, questions, answers) {
471
481
  }
472
482
 
473
483
  export function markAskUserAnswered(toolId, answers) {
474
- var container = document.querySelector('.ask-user-container[data-tool-id="' + toolId + '"]');
484
+ var container = findAskUserCard(ctx.messagesEl, toolId);
475
485
  if (container && !container.classList.contains("answered")) {
476
486
  container.classList.add("answered");
487
+ markAskUserRequestAnswered(toolId);
477
488
  enableMainInput();
478
489
  // Restore answers from history replay
479
490
  if (answers && Object.keys(answers).length > 0) {
@@ -2420,6 +2431,7 @@ export function saveToolState() {
2420
2431
  toolGroupCounter: toolGroupCounter,
2421
2432
  toolGroups: toolGroups,
2422
2433
  lastCumulativeCost: _lastCumulativeCost,
2434
+ askUserState: saveAskUserState(),
2423
2435
  };
2424
2436
  }
2425
2437
 
@@ -2435,6 +2447,7 @@ export function restoreToolState(saved) {
2435
2447
  toolGroupCounter = saved.toolGroupCounter;
2436
2448
  toolGroups = saved.toolGroups;
2437
2449
  _lastCumulativeCost = saved.lastCumulativeCost || 0;
2450
+ restoreAskUserState(saved.askUserState);
2438
2451
  if (todoWidgetEl) {
2439
2452
  setupTodoObserver();
2440
2453
  }
@@ -2442,6 +2455,7 @@ export function restoreToolState(saved) {
2442
2455
 
2443
2456
  export function resetToolState() {
2444
2457
  tools = {};
2458
+ resetAskUserState();
2445
2459
  clearThinkingState();
2446
2460
  inPlanMode = false;
2447
2461
  planContent = null;
@@ -2,6 +2,7 @@ var usersModule = require("./users");
2
2
  var yoke = require("./yoke");
3
3
  var backgroundTaskTiming = require("./background-task-timing");
4
4
  var sessionTitlePolicy = require("./session-title-policy");
5
+ var askUserIdentity = require("./ask-user-request-identity");
5
6
 
6
7
  var OVERLOAD_RETRY_LIMIT = 3;
7
8
 
@@ -379,7 +380,24 @@ function attachMessageProcessor(ctx) {
379
380
  if (block && block.type === "tool_use") {
380
381
  var input = {};
381
382
  try { input = JSON.parse(block.inputJson); } catch (e) {}
382
- sendAndRecord(session, { type: "tool_executing", id: block.id, name: block.name, input: input });
383
+ // Native AskUserQuestion is recorded when canUseTool opens the
384
+ // request. The later block_stop is the SDK's completion notification
385
+ // for that same request, not a second question. Keeping both records
386
+ // made replay render two independently editable cards and allowed the
387
+ // client to submit the same request twice.
388
+ var alreadyRecordedAsk = false;
389
+ if (block.name === "AskUserQuestion" && block.id && Array.isArray(session.history)) {
390
+ for (var ahi = session.history.length - 1; ahi >= 0; ahi--) {
391
+ var priorAsk = session.history[ahi];
392
+ if (askUserIdentity.sameAskUserRequest(priorAsk, block.id, input)) {
393
+ alreadyRecordedAsk = true;
394
+ break;
395
+ }
396
+ }
397
+ }
398
+ if (!alreadyRecordedAsk) {
399
+ sendAndRecord(session, { type: "tool_executing", id: block.id, name: block.name, input: input });
400
+ }
383
401
 
384
402
  // Track active Task tools for sub-agent done detection
385
403
  if (block.name === "Task") {
@@ -1,6 +1,12 @@
1
1
  // Partner-control tools for sessions that belong to a split group.
2
2
 
3
3
  var buildShape = require("./session-spawn-mcp-server").buildShape;
4
+ var z;
5
+ try { z = require("zod"); } catch (e) { z = null; }
6
+ var evaluationSchema = z ? z.union([
7
+ z.string(),
8
+ z.object({ outcome: z.string(), note: z.string().optional() }),
9
+ ]) : null;
4
10
 
5
11
  // `options.lifecycle` adds the autonomous management tools. They are omitted
6
12
  // for a plain side-by-side split, which has no Driver role to exercise them.
@@ -68,7 +74,7 @@ function getToolDefs(handlers, options) {
68
74
  workerEffort: { type: "string", description: "Optional reasoning effort for the new Worker." },
69
75
  recommendationRationale: { type: "string", description: "Concise Driver-authored explanation of why the recommended replacement vendor, model, and effort fit the next task." },
70
76
  evaluation: {
71
- type: "object",
77
+ schema: evaluationSchema,
72
78
  description: "Optional bounded assessment of the Worker being replaced, recorded against that exact generation.",
73
79
  },
74
80
  operationId: { type: "string", description: "Optional stable id for this replacement. Reusing it within the same human turn returns the original operation instead of replacing twice." },
@@ -69,6 +69,7 @@ function restore(meta, session) {
69
69
  createdVia: stored.createdVia === "split-worker" ? "split-worker" : null,
70
70
  createdAt: typeof stored.createdAt === "number" && isFinite(stored.createdAt) ? stored.createdAt : null,
71
71
  };
72
+ session._pairGeneration = session.sessionProvenance.generation;
72
73
  return storedOriginId !== session.sessionOriginId || JSON.stringify(stored) !== JSON.stringify(session.sessionProvenance);
73
74
  }
74
75
 
@@ -12,7 +12,8 @@ function buildShape(props, required) {
12
12
  var key = keys[i];
13
13
  var prop = props[key];
14
14
  var field;
15
- if (prop.type === "number") field = z.number();
15
+ if (prop.schema) field = prop.schema;
16
+ else if (prop.type === "number") field = z.number();
16
17
  else if (prop.type === "boolean") field = z.boolean();
17
18
  else if (prop.enum) field = z.enum(prop.enum);
18
19
  else field = z.string();
package/lib/sessions.js CHANGED
@@ -186,6 +186,7 @@ function createSessionManager(opts) {
186
186
  var provenanceMeta = sessionProvenance.metadata(session);
187
187
  metaObj.sessionOriginId = provenanceMeta.sessionOriginId;
188
188
  if (provenanceMeta.sessionProvenance) metaObj.sessionProvenance = provenanceMeta.sessionProvenance;
189
+ if (Array.isArray(session._workerGenerations)) metaObj.workerGenerations = session._workerGenerations;
189
190
  var meta = JSON.stringify(metaObj);
190
191
  var lines = [meta];
191
192
  for (var i = 0; i < session.history.length; i++) {
@@ -308,6 +309,7 @@ function createSessionManager(opts) {
308
309
  if (m.ownerId) session.ownerId = m.ownerId;
309
310
  session.sessionOriginId = m.sessionOriginId || null;
310
311
  session._provenanceMigrationNeeded = sessionProvenance.restore(m, session);
312
+ if (Array.isArray(m.workerGenerations)) session._workerGenerations = m.workerGenerations;
311
313
  // Born-TUI session: PTY is gone after restart, but the cliSessionId
312
314
  // is still resumable via `claude --resume <id>`. We mark the mode
313
315
  // here so it shows up in the sidebar with the right icon; the
@@ -193,7 +193,8 @@ function flattenEvent(notification, state) {
193
193
  if (turnStatus === "interrupted" || state.aborted) {
194
194
  events.push({ yokeType: "interrupted" });
195
195
  }
196
- var inputTokens = state.lastInputTokens || (usage ? (usage.input_tokens || 0) + (usage.cached_input_tokens || 0) : 0);
196
+ var inputTokens = state.lastInputTokens !== null && state.lastInputTokens !== undefined
197
+ ? state.lastInputTokens : (usage ? (usage.input_tokens || 0) : 0);
197
198
  var outputTokens = (usage ? (usage.output_tokens || 0) : 0) || state.lastOutputTokens || 0;
198
199
  var cachedTokens = (usage ? (usage.cached_input_tokens || 0) : 0) || state.lastCachedTokens || 0;
199
200
  var hasTokenData = inputTokens > 0 || outputTokens > 0;
@@ -217,7 +218,7 @@ function flattenEvent(notification, state) {
217
218
  } : null,
218
219
  modelUsage: resultModelUsage,
219
220
  sessionId: state.threadId || null,
220
- lastStreamInputTokens: state.lastInputTokens || null,
221
+ lastStreamInputTokens: null,
221
222
  });
222
223
  state.lastInputTokens = null;
223
224
  state.lastCachedTokens = null;
@@ -675,15 +676,28 @@ function flattenEvent(notification, state) {
675
676
  // context gauge must use the LAST turn, not the running total: each turn's
676
677
  // input already contains the whole conversation, so summing turns
677
678
  // overstates occupancy (a 5k + 8k thread reads as 13k of an 8k context).
678
- // `total` is only a fallback for servers that omit lastTurn.
679
+ // A missing lastTurn is not evidence of current context occupancy. The
680
+ // protocol's total is cumulative across the thread and must never be used
681
+ // as the context gauge.
679
682
  if (method === "thread/tokenUsage/updated") {
680
683
  var tu = params.tokenUsage;
681
684
  if (tu) {
682
- var turnUsage = tu.lastTurn || tu.total;
685
+ var turnUsage = tu.lastTurn;
683
686
  if (turnUsage) {
684
- state.lastInputTokens = (turnUsage.inputTokens || 0) + (turnUsage.cachedInputTokens || 0);
687
+ var candidateInput = typeof turnUsage.inputTokens === "number" ? turnUsage.inputTokens : null;
688
+ var currentWindow = typeof tu.modelContextWindow === "number" ? tu.modelContextWindow : null;
689
+ state.lastInputTokens = candidateInput !== null && (currentWindow === null || candidateInput <= currentWindow) ? candidateInput : null;
685
690
  state.lastCachedTokens = turnUsage.cachedInputTokens || 0;
686
691
  state.lastOutputTokens = turnUsage.outputTokens || 0;
692
+ state.currentContextUsage = state.lastInputTokens !== null &&
693
+ (currentWindow === null || state.lastInputTokens <= currentWindow)
694
+ ? { input_tokens: state.lastInputTokens, contextWindow: currentWindow }
695
+ : { input_tokens: null, contextWindow: currentWindow };
696
+ } else {
697
+ state.lastInputTokens = null;
698
+ state.lastCachedTokens = null;
699
+ state.lastOutputTokens = null;
700
+ state.currentContextUsage = null;
687
701
  }
688
702
  if (tu.modelContextWindow) state.modelContextWindow = tu.modelContextWindow;
689
703
  }
@@ -734,6 +748,7 @@ function createEventState(model) {
734
748
  lastInputTokens: null,
735
749
  lastCachedTokens: null,
736
750
  lastOutputTokens: null,
751
+ currentContextUsage: null,
737
752
  modelContextWindow: null,
738
753
  done: false,
739
754
  aborted: false,
@@ -1107,6 +1122,9 @@ function createCodexQueryHandle(appServer, queryOpts) {
1107
1122
 
1108
1123
  var threadResult;
1109
1124
  if (queryOpts.resumeSessionId) {
1125
+ // ThreadResumeParams in the installed app-server schema has no
1126
+ // dynamicTools field. New tools therefore require a new Driver/Worker
1127
+ // thread; resumed threads can only refresh handlers for persisted tools.
1110
1128
  threadResult = await appServer.send("thread/resume", {
1111
1129
  threadId: queryOpts.resumeSessionId,
1112
1130
  model: threadParams.model,
@@ -1269,11 +1287,7 @@ function createCodexQueryHandle(appServer, queryOpts) {
1269
1287
  },
1270
1288
 
1271
1289
  getContextUsage: function() {
1272
- if (state.lastInputTokens == null && state.modelContextWindow == null) return Promise.resolve(null);
1273
- return Promise.resolve({
1274
- input_tokens: state.lastInputTokens == null ? null : state.lastInputTokens,
1275
- contextWindow: state.modelContextWindow || null,
1276
- });
1290
+ return Promise.resolve(state.currentContextUsage);
1277
1291
  },
1278
1292
 
1279
1293
  abort: function() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.1.0-beta.1",
3
+ "version": "4.1.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",