clay-server 4.0.0-beta.13 → 4.0.0-beta.14

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,76 @@
1
+ // Deliver new Project Log comments to the exact live session responsible for
2
+ // the canonical entry. Comments remain durable in the ledger when that session
3
+ // is gone; this module never guesses another session or creates one.
4
+
5
+ function attachProjectLogFeedbackDelivery(ctx) {
6
+ var sm = ctx.sm;
7
+ var getSdk = ctx.getSdk;
8
+ var onProcessingChanged = ctx.onProcessingChanged || function () {};
9
+ var getLinuxUserForSession = ctx.getLinuxUserForSession || function () { return null; };
10
+
11
+ function findAuthorSession(entry) {
12
+ var author = entry && entry.updatedBy;
13
+ if (!author || author.type !== "session" || !author.sessionKey) return null;
14
+ var match = null;
15
+ sm.sessions.forEach(function (session) {
16
+ if (match || !session || session.destroying || session.hidden) return;
17
+ if (session.cliSessionId !== author.sessionKey && "local:" + session.localId !== author.sessionKey) return;
18
+ if ((session.ownerId || null) !== (author.userId || null)) return;
19
+ if (sm.sessions.get(session.localId) !== session) return;
20
+ match = session;
21
+ });
22
+ return match;
23
+ }
24
+
25
+ function feedbackPrompt(entry) {
26
+ return "[Project Log feedback]\n" +
27
+ "A user added a comment to " + entry.ref + ". Review the pending feedback now with list_log_feedback, " +
28
+ "then resolve this specific comment through review_log_comment. Apply supported corrections to the canonical " +
29
+ "entry; otherwise clarify or decline with a concise reason. Do not treat this internal notification as user " +
30
+ "chat or answer it only in the conversation.\n" +
31
+ "[End Project Log feedback]";
32
+ }
33
+
34
+ function deliver(entry) {
35
+ if (!entry || !entry.ref || !entry.pendingFeedbackCount) return false;
36
+ var session = findAuthorSession(entry);
37
+ var sdk = getSdk();
38
+ if (!session || !sdk) return false;
39
+ var text = feedbackPrompt(entry);
40
+ sm.sendAndRecord(session, {
41
+ type: "user_message",
42
+ text: text,
43
+ _internal: true,
44
+ projectLogFeedback: true,
45
+ projectLogRef: entry.ref,
46
+ });
47
+ session.lastActivity = Date.now();
48
+ if (!session.isProcessing) {
49
+ session.isProcessing = true;
50
+ session.sentToolResults = {};
51
+ onProcessingChanged();
52
+ sm.sendToSession(session, { type: "status", status: "processing" });
53
+ }
54
+ if (!sdk.pushMessage(session, text)) {
55
+ session._queryStartTs = Date.now();
56
+ Promise.resolve(sdk.startQuery(session, text, null, getLinuxUserForSession(session))).catch(function (error) {
57
+ session.isProcessing = false;
58
+ sm.sendAndRecord(session, {
59
+ type: "error",
60
+ text: "Could not deliver Project Log feedback: " + (error.message || String(error)),
61
+ _internal: true,
62
+ });
63
+ onProcessingChanged();
64
+ });
65
+ }
66
+ sm.broadcastSessionList();
67
+ return true;
68
+ }
69
+
70
+ return {
71
+ deliver: deliver,
72
+ findAuthorSession: findAuthorSession,
73
+ };
74
+ }
75
+
76
+ module.exports = { attachProjectLogFeedbackDelivery: attachProjectLogFeedbackDelivery };
@@ -88,6 +88,7 @@ function attachProjectLogs(ctx) {
88
88
  var sendTo = ctx.sendTo;
89
89
  var getClients = ctx.getClients || function () { return []; };
90
90
  var getProjectOwnerId = ctx.getProjectOwnerId || function () { return null; };
91
+ var onFeedback = ctx.onFeedback || function () {};
91
92
 
92
93
  // Every socket in this project's client set passed the project access check
93
94
  // at WebSocket upgrade, so a broadcast here is already scoped to authorized
@@ -204,6 +205,9 @@ function attachProjectLogs(ctx) {
204
205
  if (msg.type === "project_log_comment") {
205
206
  var commented = bound.commentLog({ ref: msg.ref, body: msg.body });
206
207
  sendTo(ws, { type: "project_log_commented", requestId: requestId, entry: commented });
208
+ try { onFeedback(commented); } catch (deliveryError) {
209
+ console.error("[project-logs] Failed to deliver comment:", deliveryError.message || deliveryError);
210
+ }
207
211
  return true;
208
212
  }
209
213
  } catch (e) {
@@ -1,13 +1,13 @@
1
1
  // Autonomous Split Worker lifecycle: bounded status, atomic replacement, and
2
2
  // the per-generation evaluation ledger.
3
3
  //
4
- // A qualified Driver manages its visible Worker without asking the user: it
4
+ // A Driver manages its visible Worker without asking the user: it
5
5
  // decides reuse against replacement from exact server-derived accounting, and
6
6
  // it replaces in one operation rather than a close/create dance. Nothing here
7
7
  // posts a proposal or waits for an approval card.
8
8
  //
9
9
  // Everything is bound to the exact live pair. The Driver is re-resolved from
10
- // the split store on every call, its eligibility is re-checked every time, and
10
+ // the split store on every call, its structural capability is re-checked every time, and
11
11
  // ownership must match on both sessions. No active-tab or global-session
12
12
  // fallback exists in this module.
13
13
  //
@@ -187,7 +187,7 @@ function attachPairLifecycle(ctx) {
187
187
  return record;
188
188
  }
189
189
 
190
- // The exact pair, with the Driver's eligibility re-checked on every call.
190
+ // The exact pair, with the Driver's structural capability re-checked on every call.
191
191
  function resolveDriverPair(caller) {
192
192
  if (!caller) throw new Error("pair lifecycle tools require a session-bound tool server");
193
193
  // Exact live object identity, before anything is read off the caller. A
@@ -206,7 +206,7 @@ function attachPairLifecycle(ctx) {
206
206
  var worker = sm.sessions.get(group.pair.workerId);
207
207
  if (!worker) throw new Error("split partner session was not found");
208
208
  if ((caller.ownerId || null) !== (worker.ownerId || null)) throw new Error("split partner access denied");
209
- return { group: group, worker: worker, tier: verdict.tier };
209
+ return { group: group, worker: worker };
210
210
  }
211
211
 
212
212
  function replaceBlockedReason(worker) {
@@ -246,7 +246,6 @@ function attachPairLifecycle(ctx) {
246
246
  orchestration: turnControl.status(caller),
247
247
  replaceSafe: !blocked,
248
248
  replaceBlockedReason: blocked,
249
- driverTier: resolved.tier ? resolved.tier.name : null,
250
249
  generations: ledger,
251
250
  };
252
251
  }
@@ -381,7 +381,7 @@ function attachSessionPair(ctx) {
381
381
  // Mount for the query lifetime; handlers re-resolve live pair state.
382
382
  var group = store.groupForMember(boundSession.localId);
383
383
  if (group && group.pair && group.pair.driverId !== boundSession.localId) return [];
384
- // Plain splits keep base tools; autonomous Drivers must pass the tier gate.
384
+ // Every project chat model may drive; the check only excludes non-chat surfaces.
385
385
  var adHocSplit = !!(group && !group.pair);
386
386
  if (!adHocSplit && !driverEligibility.isEligibleDriverSession(boundSession, sm)) return [];
387
387
  var lifecycleHandlers = lifecycle.toolHandlers(boundSession);
package/lib/project.js CHANGED
@@ -51,6 +51,7 @@ var { attachSessionDocument } = require("./project-session-document");
51
51
  var { attachCapsuleCatalog } = require("./project-capsule-catalog");
52
52
  var { attachProjectWorkspaceQuery } = require("./project-workspace-query");
53
53
  var { attachProjectLogs } = require("./project-logs");
54
+ var { attachProjectLogFeedbackDelivery } = require("./project-log-feedback-delivery");
54
55
  var { attachProjectMateKnowledge } = require("./project-mate-knowledge");
55
56
  var { attachSplitGroups } = require("./session-split-groups");
56
57
  var toolControlMcp = require("./tool-control-mcp-server");
@@ -670,6 +671,12 @@ function createProjectContext(opts) {
670
671
  isMate: isMate,
671
672
  mateId: mateId,
672
673
  });
674
+ var _logFeedbackDelivery = attachProjectLogFeedbackDelivery({
675
+ sm: sm,
676
+ getSdk: function () { return sdk; },
677
+ onProcessingChanged: onProcessingChanged,
678
+ getLinuxUserForSession: getLinuxUserForSession,
679
+ });
673
680
  var _projectLogs = attachProjectLogs({
674
681
  service: opts.projectLogsService || null,
675
682
  sm: sm,
@@ -679,6 +686,7 @@ function createProjectContext(opts) {
679
686
  mateId: mateId,
680
687
  sendTo: sendTo,
681
688
  getClients: function () { return clients; },
689
+ onFeedback: _logFeedbackDelivery.deliver,
682
690
  });
683
691
  var _mateKnowledge = attachProjectMateKnowledge({
684
692
  service: opts.mateKnowledgeService || null,
package/lib/sdk-bridge.js CHANGED
@@ -664,6 +664,14 @@ function createSDKBridge(opts) {
664
664
  return { behavior: "allow", updatedInput: input };
665
665
  }
666
666
 
667
+ // Project Logs are exact-session, project-bound tools whose authority is
668
+ // enforced again by the server binding. Canonical changes are append-only
669
+ // revisions, and the surface exposes no destructive delete operation, so
670
+ // a second provider permission prompt adds no additional safety.
671
+ if (toolName.indexOf("mcp__clay-logs__") === 0) {
672
+ return { behavior: "allow", updatedInput: input };
673
+ }
674
+
667
675
  // This tool only prepares a read-only UI projection for the document the
668
676
  // agent is about to edit. The subsequent Edit/Write keeps its own normal
669
677
  // permission behavior.
@@ -3,6 +3,8 @@ var yoke = require("./yoke");
3
3
  var backgroundTaskTiming = require("./background-task-timing");
4
4
  var sessionTitlePolicy = require("./session-title-policy");
5
5
 
6
+ var OVERLOAD_RETRY_LIMIT = 3;
7
+
6
8
  function attachMessageProcessor(ctx) {
7
9
  var sm = ctx.sm;
8
10
  var send = ctx.send;
@@ -487,6 +489,8 @@ function attachMessageProcessor(ctx) {
487
489
  }
488
490
 
489
491
  } else if (parsed.yokeType === "result") {
492
+ var overloadAbortRequested = !!session._overloadAbortRequested;
493
+ session._overloadAbortRequested = false;
490
494
  session.blocks = {};
491
495
  session.sentToolResults = {};
492
496
  session.pendingPermissions = {};
@@ -532,7 +536,9 @@ function attachMessageProcessor(ctx) {
532
536
  session._awaitingTurnResult = false;
533
537
  session._queuedTurnCount = 0;
534
538
  onProcessingChanged();
535
- sendAndRecord(session, { type: "error", text: "Claude error: " + execError });
539
+ if (!overloadAbortRequested) {
540
+ sendAndRecord(session, { type: "error", text: "Claude error: " + execError });
541
+ }
536
542
  sendAndRecord(session, { type: "done", code: 1 });
537
543
  sm.broadcastSessionList();
538
544
  return;
@@ -819,9 +825,33 @@ function attachMessageProcessor(ctx) {
819
825
  }
820
826
 
821
827
  } else if (parsed.yokeType === "api_retry") {
822
- // Transient retry notification, show in UI but don't persist in history
823
- var retryText = parsed.message || parsed.error || "Retrying API request...";
824
- sendToSession(session, { type: "system_info", text: retryText });
828
+ // The Claude SDK may otherwise paint one persistent red error for every
829
+ // retry and remain busy for many minutes. Show one transient status, and
830
+ // stop a repeatedly overloaded turn after a small bounded number of
831
+ // attempts so the user regains control and can retry or change models.
832
+ var apiRetryAttempt = parsed.attempt || 1;
833
+ if (parsed.error === "overloaded") {
834
+ if (apiRetryAttempt === 1) {
835
+ session._overloadAbortRequested = false;
836
+ sendToSession(session, {
837
+ type: "system_info",
838
+ text: "The model is overloaded. Retrying briefly...",
839
+ });
840
+ }
841
+ if (apiRetryAttempt >= OVERLOAD_RETRY_LIMIT && !session._overloadAbortRequested) {
842
+ session._overloadAbortRequested = true;
843
+ sendAndRecord(session, {
844
+ type: "error",
845
+ text: "The model remained overloaded after " + apiRetryAttempt +
846
+ " attempts, so this turn was stopped. Try again or choose another model.",
847
+ });
848
+ if (session.abortController && typeof session.abortController.abort === "function") {
849
+ try { session.abortController.abort(); } catch (e) {}
850
+ }
851
+ }
852
+ } else if (apiRetryAttempt === 1) {
853
+ sendToSession(session, { type: "system_info", text: "The model request failed temporarily. Retrying..." });
854
+ }
825
855
 
826
856
  } else if (parsed.yokeType === "commands_changed") {
827
857
  // Mid-session command list change: rebuild the union with skills (same as
@@ -1,183 +1,30 @@
1
- // Driver eligibility: which models may hold the Driver role of a visible
2
- // Driver/Split Worker pair and receive the autonomous lifecycle tools.
1
+ // Driver capability for a visible Driver/Split Worker pair.
3
2
  //
4
- // There is no canonical tier helper in the repo to reuse. Claude models are
5
- // enumerated by the SDK at runtime, so there is no static list; Codex ships a
6
- // fixed catalog in yoke/adapters/codex.js. What both have in common is a tier
7
- // token in the model id and in its display name, which the existing
8
- // project-worker-proposal.js already keys on ("fable"). This module makes that
9
- // same idea explicit, ordered, and vendor-scoped.
10
- //
11
- // Ranks are per vendor family and are only ever compared within a family; no
12
- // cross-vendor ranking is implied or invented.
13
- //
14
- // claude family haiku 1 < sonnet 2 < opus 3 < fable 4
15
- // real ids: claude-haiku-4-5-20251001, claude-sonnet-5,
16
- // claude-opus-5, claude-fable-5
17
- // Driver threshold: fable
18
- //
19
- // codex family gpt-5.2 1 < gpt-5.5 2 < luna 3 < sol 4 < terra 5
20
- // real ids: the CODEX_MODELS catalog, strongest first
21
- // Driver threshold: sol
22
- //
23
- // Matching is by token, not by exact id, so a later release in the same family
24
- // (claude-fable-6, gpt-5.7-terra) stays eligible without a code change. A
25
- // model in a known family whose tier token is unrecognized is NOT eligible:
26
- // unknown capability fails closed rather than inheriting the threshold.
27
- //
28
- // Any other vendor is not eligible for the Driver role at all, because no
29
- // equivalent tier metadata exists to compare against. Those vendors remain
30
- // perfectly usable as Workers.
31
-
32
- var models = require("./project-models");
33
-
34
- var FAMILIES = {
35
- claude: {
36
- label: "Claude",
37
- threshold: 4,
38
- thresholdName: "Fable",
39
- // Longest/most specific tokens first so "haiku" cannot shadow a future
40
- // compound name. Each entry is a token searched in id and display name.
41
- tiers: [
42
- { token: "fable", rank: 4, name: "Fable" },
43
- { token: "opus", rank: 3, name: "Opus" },
44
- { token: "sonnet", rank: 2, name: "Sonnet" },
45
- { token: "haiku", rank: 1, name: "Haiku" },
46
- ],
47
- },
48
- codex: {
49
- label: "OpenAI",
50
- threshold: 4,
51
- thresholdName: "Sol",
52
- tiers: [
53
- { token: "terra", rank: 5, name: "Terra" },
54
- { token: "sol", rank: 4, name: "Sol" },
55
- { token: "luna", rank: 3, name: "Luna" },
56
- { token: "gpt-5.5", rank: 2, name: "GPT-5.5" },
57
- { token: "gpt-5.2", rank: 1, name: "GPT-5.2" },
58
- ],
59
- },
60
- };
61
-
62
- function familyFor(vendor) {
63
- var key = String(vendor || "").toLowerCase();
64
- return Object.prototype.hasOwnProperty.call(FAMILIES, key) ? FAMILIES[key] : null;
65
- }
66
-
67
- // Every string the catalog knows this model by. The catalog entry is found with
68
- // the repo's own matcher so an alias ("fable"), an id, or a resolvedModel all
69
- // resolve to the same entry, and the display name participates in tier
70
- // detection exactly as project-worker-proposal.js already relies on.
71
- function searchTextFor(vendor, model, modelsByVendor) {
72
- var parts = [String(model || "")];
73
- var catalog = (modelsByVendor && modelsByVendor[vendor]) || [];
74
- for (var i = 0; i < catalog.length; i++) {
75
- if (!models.modelEntryMatches(catalog[i], model)) continue;
76
- var entry = catalog[i];
77
- if (typeof entry === "string") {
78
- parts.push(entry);
79
- } else {
80
- parts.push(entry.value || "");
81
- parts.push(entry.id || "");
82
- parts.push(entry.resolvedModel || "");
83
- parts.push(entry.displayName || "");
84
- parts.push(entry.name || "");
85
- }
86
- break;
87
- }
88
- return parts.join(" ").toLowerCase();
89
- }
90
-
91
- // Resolve the tier of one model inside its vendor family. Returns null when
92
- // the vendor has no tier metadata or the tier token is unrecognized.
93
- function resolveTier(vendor, model, modelsByVendor) {
94
- var family = familyFor(vendor);
95
- if (!family || !model) return null;
96
- var text = searchTextFor(vendor, model, modelsByVendor);
97
- for (var i = 0; i < family.tiers.length; i++) {
98
- if (text.indexOf(family.tiers[i].token) !== -1) {
99
- return {
100
- vendor: String(vendor).toLowerCase(),
101
- family: family.label,
102
- rank: family.tiers[i].rank,
103
- name: family.tiers[i].name,
104
- threshold: family.threshold,
105
- thresholdName: family.thresholdName,
106
- };
107
- }
108
- }
109
- return null;
110
- }
111
-
112
- // The hard invariant. Returns { ok, tier, error } and never throws. `error` is
113
- // a complete English sentence suitable for returning to a tool caller.
114
- function evaluateDriverModel(vendor, model, modelsByVendor) {
115
- var family = familyFor(vendor);
116
- if (!family) {
117
- return {
118
- ok: false,
119
- tier: null,
120
- error: "The Driver role requires a Claude model of Fable tier or higher, or an OpenAI model of " +
121
- "Sol tier or higher. Vendor \"" + String(vendor || "unknown") + "\" has no comparable tier " +
122
- "metadata, so it cannot take the Driver role. It can still be used for the Split Worker.",
123
- };
3
+ // Model and vendor choice belongs to the user. A session does not need to
4
+ // clear a model-tier threshold to coordinate a Split Worker; runtime catalog
5
+ // validation belongs to session-pair-factory.js and applies equally to every
6
+ // requested role. The only session-level exclusion here is structural: an
7
+ // embedded terminal has no project chat surface on which to host the Driver
8
+ // controls or the paired pane.
9
+
10
+ function evaluateDriverSession(session) {
11
+ if (!session) {
12
+ return { ok: false, error: "No session was bound to this request." };
124
13
  }
125
- if (!model) {
126
- return {
127
- ok: false,
128
- tier: null,
129
- error: "This session has no resolved model yet, so its " + family.label +
130
- " tier cannot be confirmed. The Driver role requires " + family.thresholdName +
131
- " tier or higher.",
132
- };
133
- }
134
- var tier = resolveTier(vendor, model, modelsByVendor);
135
- if (!tier) {
136
- return {
137
- ok: false,
138
- tier: null,
139
- error: "Model \"" + model + "\" is not a recognized " + family.label +
140
- " tier, so it cannot take the Driver role. The Driver role requires " +
141
- family.thresholdName + " tier or higher.",
142
- };
143
- }
144
- if (tier.rank < tier.threshold) {
145
- return {
146
- ok: false,
147
- tier: tier,
148
- error: "The Driver role requires a " + family.label + " model of " + family.thresholdName +
149
- " tier or higher. This session is " + tier.name + " tier.",
150
- };
151
- }
152
- return { ok: true, tier: tier, error: null };
153
- }
154
-
155
- // The session-level form. Resolves the session's effective model the same way
156
- // the rest of the server does: the session's own model, else the vendor
157
- // default. Identity is read from the session object, never from a caller.
158
- function evaluateDriverSession(session, sm) {
159
- if (!session) return { ok: false, tier: null, error: "No session was bound to this request." };
160
14
  if (session.mode === "tui") {
161
15
  return {
162
16
  ok: false,
163
- tier: null,
164
17
  error: "An embedded terminal session cannot take the Driver role.",
165
18
  };
166
19
  }
167
- var vendor = session.vendor || "claude";
168
- var fallback = (sm && sm.defaultModelByVendor && sm.defaultModelByVendor[vendor]) || "";
169
- var model = session.model || fallback;
170
- return evaluateDriverModel(vendor, model, sm && sm.modelsByVendor);
20
+ return { ok: true, error: null };
171
21
  }
172
22
 
173
- function isEligibleDriverSession(session, sm) {
174
- return evaluateDriverSession(session, sm).ok;
23
+ function isEligibleDriverSession(session) {
24
+ return evaluateDriverSession(session).ok;
175
25
  }
176
26
 
177
27
  module.exports = {
178
- FAMILIES: FAMILIES,
179
- evaluateDriverModel: evaluateDriverModel,
180
28
  evaluateDriverSession: evaluateDriverSession,
181
29
  isEligibleDriverSession: isEligibleDriverSession,
182
- resolveTier: resolveTier,
183
30
  };
@@ -22,11 +22,10 @@
22
22
  //
23
23
  // Two ordering rules matter and are load-bearing:
24
24
  //
25
- // 1. Nothing is created until everything is validated. Driver eligibility and
25
+ // 1. Nothing is created until everything is validated. Driver capability and
26
26
  // both runtime requests are settled first, so a rejected request leaves no
27
- // orphan session behind. For a newly requested Driver the tier is checked
28
- // against the model that session would actually resolve to, before either
29
- // session exists.
27
+ // orphan session behind. Any installed, available model may be the Driver;
28
+ // model choice is the user's rather than a server-side tier policy.
30
29
  // 2. `preflightRuntime` is side-effect free, so a caller that is about to
31
30
  // destroy something (replacement dissolving a live pair) can validate the
32
31
  // replacement first and abort while the old pair is still intact.
@@ -140,14 +139,6 @@ function attachPairFactory(ctx) {
140
139
  groupName = undefined; // auto name from member titles
141
140
  } else {
142
141
  driverRuntime = preflightRuntime(driverSpec, "claude");
143
- // The tier is judged on the model this session would really resolve to:
144
- // the explicit request if there is one, else that vendor's default.
145
- var effectiveDriverModel = driverRuntime.model ||
146
- (sm.defaultModelByVendor && sm.defaultModelByVendor[driverRuntime.vendor]) || "";
147
- var newVerdict = driverEligibility.evaluateDriverModel(
148
- driverRuntime.vendor, effectiveDriverModel, sm.modelsByVendor
149
- );
150
- if (!newVerdict.ok) throw new Error(newVerdict.error);
151
142
  groupName = "Agent pair";
152
143
  }
153
144
 
@@ -202,7 +193,7 @@ function attachPairFactory(ctx) {
202
193
  }
203
194
 
204
195
  function createWorkerForDriver(driver, args) {
205
- // Validates the Driver's eligibility and the whole Worker runtime before
196
+ // Validates the Driver's capability and the whole Worker runtime before
206
197
  // any session is made, so a rejection creates nothing.
207
198
  var runtime = preflightWorkerForDriver(driver, args);
208
199
  var ws = {
@@ -27,7 +27,7 @@ function getToolDefs(handlers) {
27
27
  return [
28
28
  {
29
29
  name: "spawn_sessions",
30
- description: "Create sibling work sessions in this project. Set forkFromCurrent to copy this session's full conversation context into every child. Children share the project's working directory, so prefer parallel analysis or other non-conflicting work over concurrent edits. Results can be polled with check_spawned_sessions; spawned children cannot spawn further sessions.",
30
+ description: "Create ordinary sibling work sessions for background parallel work. This does not create a visible Driver/Split Worker pair and must not be used when the user asks for a Worker, paired pane, or split collaborator; use send_to_partner for that. Set forkFromCurrent to copy this session's full conversation context into every child. Children share the project's working directory, so prefer parallel analysis or other non-conflicting work over concurrent edits. Results can be polled with check_spawned_sessions; spawned children cannot spawn further sessions.",
31
31
  inputSchema: buildShape({
32
32
  sessions: { type: "string", description: "JSON array of objects: [{\"title\":\"Task title\",\"prompt\":\"Task instructions\"}]" },
33
33
  vendor: { type: "string", description: "Optional vendor id. Defaults to the parent session vendor, then the project default." },
@@ -260,6 +260,18 @@ function flattenEvent(raw) {
260
260
  base.status = raw.status;
261
261
  return base;
262
262
  }
263
+ // API retry notices are transient transport state, not conversation
264
+ // errors. Preserve the structured counters so the host can bound overload
265
+ // churn without recording one red error row per SDK retry.
266
+ if (raw.subtype === "api_retry") {
267
+ base.yokeType = "api_retry";
268
+ base.attempt = raw.attempt || 0;
269
+ base.maxRetries = raw.max_retries || 0;
270
+ base.retryDelayMs = raw.retry_delay_ms || 0;
271
+ base.error = raw.error || "unknown";
272
+ base.errorStatus = raw.error_status == null ? null : raw.error_status;
273
+ return base;
274
+ }
263
275
  if (raw.subtype === "task_started") {
264
276
  base.yokeType = "task_started";
265
277
  base.parentToolId = raw.tool_use_id;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.0.0-beta.13",
3
+ "version": "4.0.0-beta.14",
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",