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

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.
@@ -15,15 +15,10 @@ import { getWs } from './ws-ref.js';
15
15
  import { refreshIcons } from './icons.js';
16
16
  import { showToast } from './utils.js';
17
17
  import { closeScheduler } from './scheduler.js';
18
- import { hideNotes } from './sticky-notes.js';
19
18
  import { closeNotesBrowser } from './sticky-notes-browser.js';
20
19
  import { closeFileViewer } from './filebrowser.js';
21
20
  import { closeTerminal } from './terminal.js';
22
21
  import { renderFilter, renderList, renderDetail } from './project-logs-render.js';
23
- import {
24
- initProjectLogsAmbient, bindPanelHover, noteCanonicalUpdate,
25
- acknowledgeUpdates, resetAmbient, syncAmbient, hidePreview, pinFromPreview
26
- } from './project-logs-ambient.js';
27
22
 
28
23
  var panel = null;
29
24
  var listEl = null;
@@ -34,6 +29,37 @@ var backBtn = null;
34
29
  var searchTimer = null;
35
30
  var requestCounter = 0;
36
31
 
32
+ function syncUnreadBadge() {
33
+ var button = document.getElementById("project-logs-btn");
34
+ if (!button) return;
35
+ var unread = store.get('projectLogsUnread') || 0;
36
+ button.classList.toggle("project-logs-unread", unread > 0);
37
+ var badge = document.getElementById("project-logs-count");
38
+ if (!badge) return;
39
+ badge.textContent = unread > 0 ? String(unread) : "";
40
+ badge.classList.toggle("hidden", unread < 1);
41
+ }
42
+
43
+ function acknowledgeUpdates() {
44
+ if (!store.get('projectLogsUnread')) return;
45
+ store.set({ projectLogsUnread: 0 });
46
+ syncUnreadBadge();
47
+ }
48
+
49
+ function noteCanonicalUpdate(msg) {
50
+ if (!msg || !msg.ref || !msg.revision) return false;
51
+ var seen = store.get('projectLogsSeenRevisions') || {};
52
+ if (seen[msg.ref] >= msg.revision) return false;
53
+ var next = Object.assign({}, seen);
54
+ next[msg.ref] = msg.revision;
55
+ store.set({
56
+ projectLogsSeenRevisions: next,
57
+ projectLogsUnread: store.get('projectLogsOpen') ? 0 : (store.get('projectLogsUnread') || 0) + 1,
58
+ });
59
+ syncUnreadBadge();
60
+ return true;
61
+ }
62
+
37
63
  function nextRequestId(prefix) {
38
64
  requestCounter += 1;
39
65
  return prefix + "-" + Date.now() + "-" + requestCounter;
@@ -80,11 +106,7 @@ function ensurePanel() {
80
106
  backBtn = panel.querySelector("#project-logs-back");
81
107
 
82
108
  panel.querySelector("#project-logs-close").addEventListener("click", closeProjectLogs);
83
- backBtn.addEventListener("click", function () { pinOnInteraction(); showList(); });
84
- // Any meaningful interaction inside a preview commits to it.
85
- searchEl.addEventListener("focus", pinOnInteraction);
86
- panel.addEventListener("pointerdown", pinOnInteraction);
87
- bindPanelHover(panel);
109
+ backBtn.addEventListener("click", showList);
88
110
  panel.querySelector("#project-logs-wide").addEventListener("click", function () {
89
111
  applyWindowState(!store.get('projectLogsWide'), store.get('projectLogsFullscreen'));
90
112
  });
@@ -98,24 +120,6 @@ function ensurePanel() {
98
120
  refreshIcons();
99
121
  }
100
122
 
101
- // A preview reveals the pane without committing to it: no acknowledgement, no
102
- // open state, and no list refresh beyond what is already on screen.
103
- function revealPreview() {
104
- ensurePanel();
105
- if (!panel) return;
106
- panel.classList.remove("hidden");
107
- panel.classList.add("project-logs-previewing");
108
- applyWindowState(store.get('projectLogsWide'), false);
109
- if (!store.get('projectLogsEntries') || !store.get('projectLogsEntries').length) requestList();
110
- }
111
-
112
- function hideRevealedPreview() {
113
- if (!panel) return;
114
- panel.classList.remove("project-logs-previewing");
115
- if (store.get('projectLogsOpen')) return;
116
- panel.classList.add("hidden");
117
- }
118
-
119
123
  // Width and fullscreen are transient view state, so they live in the store like
120
124
  // every other mutable UI value. `panel-fullscreen` is the shared class the
121
125
  // document viewer and terminal already use.
@@ -147,13 +151,6 @@ function showList() {
147
151
  listEl.scrollTop = store.get('projectLogsListScroll') || 0;
148
152
  }
149
153
 
150
- // A preview becomes a real open the moment the user does something with it.
151
- function pinOnInteraction() {
152
- if (store.get('projectLogsOpen')) return;
153
- if (!store.get('projectLogsPreview')) return;
154
- pinFromPreview();
155
- }
156
-
157
154
  function showDetail(entry) {
158
155
  if (!panel || !entry) return;
159
156
  store.set({ projectLogsListScroll: listEl.scrollTop, projectLogsView: "detail", projectLogsSelectedRef: entry.ref });
@@ -170,12 +167,21 @@ function showDetail(entry) {
170
167
  function requestList() {
171
168
  var requestId = nextRequestId("list-logs");
172
169
  store.set({ projectLogsListRequestId: requestId });
173
- send({
170
+ if (listEl && !store.get('projectLogsEntries').length) {
171
+ listEl.setAttribute("aria-busy", "true");
172
+ renderList(listEl, [], requestEntry, "Loading the project ledger...");
173
+ }
174
+ if (!send({
174
175
  type: "project_logs_list",
175
176
  requestId: requestId,
176
177
  query: searchEl ? searchEl.value.trim() : "",
177
178
  category: store.get('projectLogsCategory') || "",
178
- });
179
+ })) {
180
+ if (listEl) {
181
+ listEl.removeAttribute("aria-busy");
182
+ renderList(listEl, [], requestEntry, "Logs are unavailable while disconnected.");
183
+ }
184
+ }
179
185
  }
180
186
 
181
187
  function requestEntry(ref) {
@@ -185,7 +191,6 @@ function requestEntry(ref) {
185
191
  }
186
192
 
187
193
  function submitComment(ref, body, statusEl, inputEl) {
188
- pinOnInteraction();
189
194
  var requestId = nextRequestId("comment-log");
190
195
  store.set({ projectLogsCommentRequestId: requestId, projectLogsCommentInput: inputEl || null });
191
196
  if (!send({ type: "project_log_comment", requestId: requestId, ref: ref, body: body })) {
@@ -197,7 +202,6 @@ function submitComment(ref, body, statusEl, inputEl) {
197
202
  }
198
203
 
199
204
  function applyFilter(category) {
200
- pinOnInteraction();
201
205
  store.set({ projectLogsCategory: category || "" });
202
206
  requestList();
203
207
  }
@@ -211,18 +215,13 @@ export function initProjectLogs() {
211
215
  if (store.get('projectLogsOpen')) closeProjectLogs();
212
216
  else openProjectLogs();
213
217
  });
214
- initProjectLogsAmbient({
215
- reveal: revealPreview,
216
- hide: hideRevealedPreview,
217
- pin: openProjectLogs,
218
- });
218
+ syncUnreadBadge();
219
219
  store.subscribe(function (state, previous) {
220
220
  if (state.currentSlug === previous.currentSlug) return;
221
221
  closeProjectLogs();
222
222
  applyWindowState(false, false);
223
223
  // A different project must never inherit the previous project's unread
224
- // state or animate its updates.
225
- resetAmbient();
224
+ // state.
226
225
  store.set({
227
226
  projectLogsEntries: [],
228
227
  projectLogsSelectedRef: null,
@@ -231,8 +230,11 @@ export function initProjectLogs() {
231
230
  projectLogsListScroll: 0,
232
231
  projectLogsListRequestId: null,
233
232
  projectLogsReadRequestId: null,
234
- projectLogsCommentRequestId: null
233
+ projectLogsCommentRequestId: null,
234
+ projectLogsUnread: 0,
235
+ projectLogsSeenRevisions: {}
235
236
  });
237
+ syncUnreadBadge();
236
238
  });
237
239
  }
238
240
 
@@ -243,37 +245,31 @@ export function openProjectLogs() {
243
245
  if (!panel) return;
244
246
  closeScheduler();
245
247
  closeNotesBrowser();
246
- hideNotes();
247
248
  // Claim the single right workbench slot.
248
249
  try { closeFileViewer(); } catch (e) {}
249
250
  try { closeTerminal(); } catch (e) {}
250
251
  panel.classList.remove("hidden");
251
- panel.classList.remove("project-logs-previewing");
252
252
  applyWindowState(store.get('projectLogsWide'), false);
253
253
  var button = document.getElementById("project-logs-btn");
254
254
  if (button) button.classList.add("active");
255
- store.set({ projectLogsOpen: true, projectLogsPinned: true, projectLogsPreview: false });
256
- // Opening the ledger is the acknowledgement; the ambient cues clear here and
257
- // nowhere else.
255
+ store.set({ projectLogsOpen: true });
256
+ // Opening through the explicit tool button acknowledges the live badge.
258
257
  acknowledgeUpdates();
259
258
  showList();
260
259
  requestList();
261
260
  }
262
261
 
263
262
  export function closeProjectLogs() {
264
- hidePreview();
265
263
  if (!store.get('projectLogsOpen')) return;
266
264
  if (panel) {
267
265
  panel.classList.add("hidden");
268
- panel.classList.remove("project-logs-previewing");
269
266
  }
270
267
  // Fullscreen is always dropped on close so the next open is a bounded pane
271
268
  // and never silently hides the conversation.
272
269
  applyWindowState(store.get('projectLogsWide'), false);
273
270
  var button = document.getElementById("project-logs-btn");
274
271
  if (button) button.classList.remove("active");
275
- store.set({ projectLogsOpen: false, projectLogsPinned: false });
276
- syncAmbient();
272
+ store.set({ projectLogsOpen: false });
277
273
  }
278
274
 
279
275
  // --- Server messages -----------------------------------------------------
@@ -282,10 +278,13 @@ export function handleProjectLogsState(msg) {
282
278
  if (msg.requestId && msg.requestId !== store.get('projectLogsListRequestId')) return;
283
279
  store.set({ projectLogsEntries: msg.entries || [] });
284
280
  if (!listEl) return;
281
+ listEl.removeAttribute("aria-busy");
285
282
  if (filterEl && Array.isArray(msg.categories)) {
286
283
  renderFilter(filterEl, msg.categories, store.get('projectLogsCategory'), applyFilter);
287
284
  }
288
- renderList(listEl, msg.entries || [], requestEntry);
285
+ var isFiltered = !!((searchEl && searchEl.value.trim()) || store.get('projectLogsCategory'));
286
+ renderList(listEl, msg.entries || [], requestEntry,
287
+ isFiltered ? "No entries match this search or category." : "No logs yet. This project's agent sessions record decisions and work here.");
289
288
  if (store.get('projectLogsView') !== "detail") listEl.scrollTop = store.get('projectLogsListScroll') || 0;
290
289
  }
291
290
 
@@ -309,8 +308,8 @@ export function handleProjectLogCommented(msg) {
309
308
  requestList();
310
309
  }
311
310
 
312
- // A canonical revision landed. This marks the ambient cues and never opens,
313
- // focuses, or scrolls anything.
311
+ // A canonical revision landed. This marks the explicit button only; it never
312
+ // opens, reveals, focuses, or scrolls the pane.
314
313
  export function handleProjectLogUpdated(msg) {
315
314
  noteCanonicalUpdate(msg);
316
315
  // Refresh the ledger only when it is already on screen, so the row moves to
@@ -325,5 +324,9 @@ export function handleProjectLogsError(msg) {
325
324
  store.get('projectLogsCommentRequestId')
326
325
  ];
327
326
  if (msg.requestId && pending.indexOf(msg.requestId) === -1) return;
327
+ if (msg.requestId && msg.requestId === store.get('projectLogsListRequestId') && listEl) {
328
+ listEl.removeAttribute("aria-busy");
329
+ renderList(listEl, [], requestEntry, "The ledger could not be loaded. Try opening Logs again.");
330
+ }
328
331
  showToast(msg.message || "Project Logs could not complete the request.", "error");
329
332
  }
@@ -16,3 +16,11 @@ export function findSplitGroup(groups, memberIds) {
16
16
  }
17
17
  return null;
18
18
  }
19
+
20
+ export function isConfiguredWorker(groups, sessionId) {
21
+ var list = groups || [];
22
+ for (var i = 0; i < list.length; i++) {
23
+ if (list[i].pair && list[i].pair.workerId === sessionId) return true;
24
+ }
25
+ return false;
26
+ }
@@ -7,7 +7,7 @@ import { iconHtml, refreshIcons } from './icons.js';
7
7
  import { detachTuiView } from './session-tui-view.js';
8
8
  import { formatTokens } from './app-panels.js';
9
9
  import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
10
- import { groupedSessionIds, findSplitGroup } from './split-group-helpers.js';
10
+ import { groupedSessionIds, findSplitGroup, isConfiguredWorker } from './split-group-helpers.js';
11
11
  import { showConfirm } from './app-misc.js';
12
12
  import { syncPairChrome } from './split-pair-ui.js';
13
13
  import { presentMarkdownEdit } from './filebrowser.js';
@@ -159,7 +159,10 @@ export function syncPaneTitles() {
159
159
  function updatePaneFullAccessButton(button, session) {
160
160
  if (!button) return;
161
161
  var mode = session && (session.runtimeMode || session.mode || "gui");
162
- var visible = !!session && !store.get('skipPermsEnabled') && mode === "gui";
162
+ var worker = !!session && isConfiguredWorker(store.get('splitGroups'), session.id);
163
+ // Permission policy belongs to the Driver. A configured Worker inherits it
164
+ // server-side and must not present a second, misleading control.
165
+ var visible = !!session && !worker && !store.get('skipPermsEnabled') && mode === "gui";
163
166
  var enabled = visible && session.permissionMode === "bypassPermissions";
164
167
  button.classList.toggle("hidden", !visible);
165
168
  button.classList.toggle("active", enabled);
@@ -203,6 +203,23 @@ export function toolActivityText(name, input) {
203
203
  return "Running " + name + "...";
204
204
  }
205
205
 
206
+ // The sublabel for a call that has finished. `toolActivityText` is written in
207
+ // the present progressive for a call in flight, so re-asserting it after a
208
+ // result has arrived leaves a finished card claiming to still be running —
209
+ // which is how a failed custom tool ended up reading "Running
210
+ // send_to_partner..." beside its own error output.
211
+ //
212
+ // Only ever used where a result exists, so a genuinely in-flight call is never
213
+ // relabelled. A failure states so plainly; a success keeps whatever specific
214
+ // description the activity text produced ("Reading foo.js"), because that still
215
+ // describes the call accurately, and only the generic present-progressive
216
+ // fallback is replaced.
217
+ export function toolTerminalText(name, input, isError) {
218
+ if (isError) return name + " failed";
219
+ var activity = toolActivityText(name, input);
220
+ return activity === "Running " + name + "..." ? name : activity;
221
+ }
222
+
206
223
  function shortPath(p) {
207
224
  if (!p) return "";
208
225
  var parts = p.split("/");
@@ -2136,9 +2153,12 @@ export function updateToolResult(id, content, isError, images) {
2136
2153
  var tool = tools[id];
2137
2154
  if (!tool) return;
2138
2155
 
2156
+ // Re-render the sublabel from the now-complete input (streaming can have
2157
+ // built it from a partial one), but in its terminal form: a result has
2158
+ // arrived, so this call is no longer running.
2139
2159
  var subtitleText = tool.el.querySelector(".tool-subtitle-text");
2140
2160
  if (subtitleText && tool.input) {
2141
- subtitleText.textContent = toolActivityText(tool.name, tool.input);
2161
+ subtitleText.textContent = toolTerminalText(tool.name, tool.input, isError);
2142
2162
  }
2143
2163
 
2144
2164
  var resultBlock = document.createElement("div");
package/lib/sdk-bridge.js CHANGED
@@ -12,6 +12,7 @@ var { createMessageQueue } = require("./sdk-message-queue");
12
12
  var { attachMessageProcessor } = require("./sdk-message-processor");
13
13
  var homeDebateToolPolicy = require("./home-debate-tool-policy");
14
14
  var yoke = require("./yoke");
15
+ var sessionTitlePolicy = require("./session-title-policy");
15
16
 
16
17
  // Extract serializable tool descriptors from MCP server instances.
17
18
  // Used for IPC to worker processes (McpSdkServerConfigWithInstance is not serializable).
@@ -1608,24 +1609,17 @@ function createSDKBridge(opts) {
1608
1609
  sessionToolDefs = sessionToolDefs.concat(yoke.userInput.fallbackToolDefs(sessionUserInputHandler));
1609
1610
  }
1610
1611
 
1611
- // Derive an explicit session title for fresh queries so the SDK records
1612
- // it at session creation and skips its own auto-generation. This also
1613
- // lets us short-circuit autoGenerateTitle below for the common case.
1612
+ // Give fresh queries an immediate provisional label. The visible user
1613
+ // message in history wins over `text`, which may contain an internal
1614
+ // provider wrapper rather than what the person typed.
1614
1615
  // Only applied to NEW sessions (no cliSessionId yet) — when resuming,
1615
1616
  // the SDK ignores Options.title in favor of the persisted title.
1616
1617
  var initialTitle = null;
1618
+ var initialTitleProvisional = false;
1617
1619
  if (!session.cliSessionId && !session.titleManuallySet && !session.titleAutoGenerated) {
1618
- if (session.title) {
1619
- // Loop / scheduled / mate-seeded sessions arrive with a title already set.
1620
- initialTitle = session.title;
1621
- } else if (typeof text === "string") {
1622
- // Derive a quick first-line snippet from the user's first message.
1623
- // Skip if too short to be meaningful — fall back to autoGenerateTitle.
1624
- var firstLine = text.replace(/\s+/g, " ").trim();
1625
- if (firstLine.length >= 10) {
1626
- initialTitle = firstLine.length > 60 ? firstLine.substring(0, 60) : firstLine;
1627
- }
1628
- }
1620
+ var initialTitleChoice = sessionTitlePolicy.selectInitialTitle(session, text);
1621
+ initialTitle = initialTitleChoice.title;
1622
+ initialTitleProvisional = initialTitleChoice.provisional;
1629
1623
  }
1630
1624
 
1631
1625
  var queryOpts = {
@@ -1720,16 +1714,16 @@ function createSDKBridge(opts) {
1720
1714
  try {
1721
1715
  handle = await sessionAdapter.createQuery(queryOpts);
1722
1716
  console.log("[sdk-bridge] createQuery returned handle, vendor=" + sessionAdapter.vendor);
1723
- // SDK accepted the explicit title adopt it locally so the session
1724
- // list reflects it immediately and autoGenerateTitle skips this
1725
- // session (titleAutoGenerated gates re-trigger).
1717
+ // SDK accepted the explicit title. Provisional labels remain eligible
1718
+ // for semantic generation after the first Home exchange (or the normal
1719
+ // project threshold); seeded titles remain fixed.
1726
1720
  if (initialTitle && !session.title) {
1727
1721
  session.title = initialTitle;
1728
- session.titleAutoGenerated = true;
1722
+ session.titleAutoGenerated = !initialTitleProvisional;
1729
1723
  sm.saveSessionFile(session);
1730
1724
  sm.broadcastSessionList();
1731
1725
  } else if (initialTitle && session.title === initialTitle) {
1732
- session.titleAutoGenerated = true;
1726
+ session.titleAutoGenerated = !initialTitleProvisional;
1733
1727
  sm.saveSessionFile(session);
1734
1728
  }
1735
1729
  } catch (e) {
@@ -1,6 +1,7 @@
1
1
  var usersModule = require("./users");
2
2
  var yoke = require("./yoke");
3
3
  var backgroundTaskTiming = require("./background-task-timing");
4
+ var sessionTitlePolicy = require("./session-title-policy");
4
5
 
5
6
  function attachMessageProcessor(ctx) {
6
7
  var sm = ctx.sm;
@@ -23,8 +24,6 @@ function attachMessageProcessor(ctx) {
23
24
  var saveImageFile = ctx.saveImageFile;
24
25
  var getSessionLinuxUser = ctx.getSessionLinuxUser || function () { return null; };
25
26
 
26
- var AUTO_TITLE_TURN_THRESHOLD = 2;
27
-
28
27
  function getMateIdForNotification() {
29
28
  if (!isMate) return null;
30
29
  if (typeof slug === "string" && slug.indexOf("mate-") === 0) {
@@ -625,7 +624,7 @@ function attachMessageProcessor(ctx) {
625
624
  sm.broadcastSessionList();
626
625
 
627
626
  // Auto-generate title after N turns (skip if loop or already auto-generated)
628
- if (session.turnCount === AUTO_TITLE_TURN_THRESHOLD
627
+ if (session.turnCount === sessionTitlePolicy.generationTurn(isMate)
629
628
  && !session.titleAutoGenerated
630
629
  && !session.titleManuallySet
631
630
  && !session.loop
@@ -9,6 +9,7 @@ var attachHomeMateCreation = require("./server-home-mate-creation").attachHomeMa
9
9
  var attachHomeDebates = require("./server-home-debates").attachHomeDebates, attachHomeClayEntry = require("./server-home-clay-entry").attachHomeClayEntry;
10
10
  var homeChatEvents = require("./server-home-chat-events");
11
11
  var homeCapsuleCreation = require("./server-home-capsule-creation");
12
+ var sessionTitlePolicy = require("./session-title-policy");
12
13
  var historyToHomeChat = homeChatEvents.historyToHomeChat;
13
14
  var transformEvent = homeChatEvents.transformEvent;
14
15
  function attachHomeChat(deps) {
@@ -84,6 +85,10 @@ function attachHomeChat(deps) {
84
85
  var result = [];
85
86
  sessionManager.sessions.forEach(function (session) {
86
87
  if (!ownsSession(session, userId)) return;
88
+ if (sessionTitlePolicy.repairLegacySearchTitle(session)
89
+ && typeof sessionManager.saveSessionFile === "function") {
90
+ sessionManager.saveSessionFile(session);
91
+ }
87
92
  var cliSessionId = sessionString(session.cliSessionId, 512);
88
93
  var createdAt = sessionTimestamp(session.createdAt);
89
94
  var lastActivity = sessionTimestamp(session.lastActivity) || createdAt;
@@ -9,6 +9,17 @@
9
9
  // Ownership always comes from the connection or the Driver session, never from
10
10
  // the incoming message.
11
11
  //
12
+ // CONTEXT CONTRACT: this module reads `sm`, `splitStore`, `isMate`,
13
+ // `usersModule` and `sendTo` off its own argument, all at the top level.
14
+ // attachSessionPair's context already carries all five, so it is passed
15
+ // through unchanged. Re-wrapping it (it was once `{ sm, splitStore, ctx }`)
16
+ // leaves `isMate`, `usersModule` and `sendTo` undefined, which silently
17
+ // disables the Mate guard, breaks the pair_session_create reply, and throws
18
+ // "Cannot read properties of undefined (reading 'isMultiUser')" the moment a
19
+ // pair is created for an owned session — single-user installs escape it only
20
+ // because a Driver with no ownerId short-circuits that expression. Add any new
21
+ // dependency as a top-level field of that same context.
22
+ //
12
23
  // Two ordering rules matter and are load-bearing:
13
24
  //
14
25
  // 1. Nothing is created until everything is validated. Driver eligibility and
@@ -17,6 +17,7 @@ function getToolDefs(handlers, options) {
17
17
  workerVendor: { type: "string", description: "Optional Split Worker vendor to use only when creating a new pair. Defaults to a suitable installed vendor." },
18
18
  workerModel: { type: "string", description: "Optional Split Worker model to use only when creating a new pair." },
19
19
  workerEffort: { type: "string", description: "Optional Split Worker reasoning effort to use only when creating a new pair." },
20
+ operationId: { type: "string", description: "Optional stable id for this delegation. Reusing it within the same human turn returns the original operation instead of sending twice." },
20
21
  }, ["message"]),
21
22
  handler: function (args) { return handlers.send(args || {}); },
22
23
  },
@@ -64,6 +65,7 @@ function getToolDefs(handlers, options) {
64
65
  type: "object",
65
66
  description: "Optional bounded assessment of the Worker being replaced, recorded against that exact generation.",
66
67
  },
68
+ 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." },
67
69
  }),
68
70
  handler: function (args) { return handlers.replace(args || {}); },
69
71
  },
@@ -23,9 +23,9 @@ var DRIVER = [
23
23
  "the user and without posting any suggestion or approval card. send_to_partner creates and opens a visible",
24
24
  "Split Worker when none exists. replace_partner dissolves the current pair and opens a fresh compact Worker in",
25
25
  "one step, optionally delivering the next task with it; the replaced Worker keeps its conversation, so nothing",
26
- "is lost. Replacing an actively running Worker requires interrupt true, which stops it first. The human may",
27
- "still message or stop the Split Worker directly; treat that intervention as authoritative, and use",
28
- "close_partner immediately when they ask to close or dismiss the pane.",
26
+ "is lost. Replacing an actively running Worker requires interrupt true, which stops it first. A human Stop",
27
+ "is authoritative: do not retry, send more work, or replace the Worker in the same turn. Clay blocks those",
28
+ "actions until the human sends a new Driver message. Use close_partner when they ask to close the pane.",
29
29
  "",
30
30
  "Choose the Worker vendor, model, and effort for the task from what is actually installed and offered; an",
31
31
  "unavailable choice is refused rather than silently substituted. After a Worker generation finishes or is",
@@ -0,0 +1,153 @@
1
+ // Per-human-turn safety policy for Driver/Split Worker orchestration.
2
+ //
3
+ // A human Stop is a cancellation barrier, not merely an AbortController
4
+ // signal. It blocks delegation and Worker replacement until the next ordinary
5
+ // human message reaches the Driver. Internal result and permission messages do
6
+ // not clear it. Creation budgets and operation ids also make retries bounded
7
+ // and idempotent even when no Stop occurred.
8
+
9
+ var MAX_CREATIONS_PER_TURN = 2;
10
+ var MAX_REPLACEMENTS_PER_TURN = 1;
11
+ var MAX_OPERATION_ID_CHARS = 120;
12
+
13
+ function attachPairTurnControl(ctx) {
14
+ var sm = ctx.sm;
15
+ var store = ctx.splitStore;
16
+
17
+ function stateFor(driver) {
18
+ if (!driver._pairTurnControl) {
19
+ driver._pairTurnControl = {
20
+ serial: 0,
21
+ humanStopped: false,
22
+ stoppedAt: null,
23
+ stoppedWorkerId: null,
24
+ creations: 0,
25
+ replacements: 0,
26
+ operations: Object.create(null),
27
+ };
28
+ }
29
+ return driver._pairTurnControl;
30
+ }
31
+
32
+ function liveSession(session) {
33
+ return !!(session && sm.sessions.get(session.localId) === session);
34
+ }
35
+
36
+ function rolesFor(session) {
37
+ if (!liveSession(session)) return null;
38
+ var group = store.groupForMember(session.localId);
39
+ if (!group || !group.pair) return null;
40
+ var driver = sm.sessions.get(group.pair.driverId);
41
+ var worker = sm.sessions.get(group.pair.workerId);
42
+ if (!liveSession(driver) || !liveSession(worker)) return null;
43
+ if ((driver.ownerId || null) !== (worker.ownerId || null)) return null;
44
+ return { group: group, driver: driver, worker: worker };
45
+ }
46
+
47
+ function beginHumanTurn(session) {
48
+ if (!liveSession(session)) return false;
49
+ var roles = rolesFor(session);
50
+ var driver = roles ? roles.driver : session;
51
+ if (roles && roles.driver !== session) return false;
52
+ var state = stateFor(driver);
53
+ state.serial += 1;
54
+ state.humanStopped = false;
55
+ state.stoppedAt = null;
56
+ state.stoppedWorkerId = null;
57
+ state.creations = 0;
58
+ state.replacements = 0;
59
+ state.operations = Object.create(null);
60
+ return true;
61
+ }
62
+
63
+ function markHumanStop(session) {
64
+ var roles = rolesFor(session);
65
+ if (!roles) return null;
66
+ var state = stateFor(roles.driver);
67
+ state.humanStopped = true;
68
+ state.stoppedAt = Date.now();
69
+ state.stoppedWorkerId = roles.worker.localId;
70
+ return roles;
71
+ }
72
+
73
+ function blockedReason(driver) {
74
+ if (!liveSession(driver)) return "this Driver session is no longer live";
75
+ var state = stateFor(driver);
76
+ if (!state.humanStopped) return null;
77
+ return "the human stopped this Split Worker turn; Worker actions are blocked until the human sends a new message to the Driver";
78
+ }
79
+
80
+ function assertWorkerAction(driver) {
81
+ var reason = blockedReason(driver);
82
+ if (reason) throw new Error(reason);
83
+ }
84
+
85
+ function reserveCreation(driver, kind) {
86
+ assertWorkerAction(driver);
87
+ var state = stateFor(driver);
88
+ if (kind === "replace" && state.replacements >= MAX_REPLACEMENTS_PER_TURN) {
89
+ throw new Error("the Split Worker replacement limit for this human turn has been reached; wait for a new human message before replacing it again");
90
+ }
91
+ if (state.creations >= MAX_CREATIONS_PER_TURN) {
92
+ throw new Error("the Split Worker creation limit for this human turn has been reached; wait for a new human message before creating another Worker");
93
+ }
94
+ state.creations += 1;
95
+ if (kind === "replace") state.replacements += 1;
96
+ return { driver: driver, kind: kind, active: true };
97
+ }
98
+
99
+ function releaseCreation(ticket) {
100
+ if (!ticket || !ticket.active || !liveSession(ticket.driver)) return;
101
+ ticket.active = false;
102
+ var state = stateFor(ticket.driver);
103
+ state.creations = Math.max(0, state.creations - 1);
104
+ if (ticket.kind === "replace") state.replacements = Math.max(0, state.replacements - 1);
105
+ }
106
+
107
+ function operationId(value) {
108
+ if (typeof value !== "string") return "";
109
+ var clean = value.trim();
110
+ if (!clean || clean.length > MAX_OPERATION_ID_CHARS || !/^[A-Za-z0-9._:-]+$/.test(clean)) return "";
111
+ return clean;
112
+ }
113
+
114
+ function runOperation(driver, kind, rawId, fn) {
115
+ var id = operationId(rawId);
116
+ if (!id) return Promise.resolve().then(fn);
117
+ var state = stateFor(driver);
118
+ var key = kind + ":" + id;
119
+ if (state.operations[key]) return state.operations[key];
120
+ var promise = Promise.resolve().then(fn);
121
+ state.operations[key] = promise;
122
+ return promise;
123
+ }
124
+
125
+ function status(driver) {
126
+ var state = stateFor(driver);
127
+ return {
128
+ humanStopped: state.humanStopped,
129
+ stoppedAt: state.stoppedAt,
130
+ stoppedWorkerId: state.stoppedWorkerId,
131
+ turnSerial: state.serial,
132
+ creationsThisTurn: state.creations,
133
+ replacementsThisTurn: state.replacements,
134
+ };
135
+ }
136
+
137
+ return {
138
+ assertWorkerAction: assertWorkerAction,
139
+ beginHumanTurn: beginHumanTurn,
140
+ blockedReason: blockedReason,
141
+ markHumanStop: markHumanStop,
142
+ releaseCreation: releaseCreation,
143
+ reserveCreation: reserveCreation,
144
+ runOperation: runOperation,
145
+ status: status,
146
+ };
147
+ }
148
+
149
+ module.exports = {
150
+ MAX_CREATIONS_PER_TURN: MAX_CREATIONS_PER_TURN,
151
+ MAX_REPLACEMENTS_PER_TURN: MAX_REPLACEMENTS_PER_TURN,
152
+ attachPairTurnControl: attachPairTurnControl,
153
+ };
@@ -280,7 +280,10 @@ function attachSplitGroups(ctx) {
280
280
  });
281
281
 
282
282
  function sendState(ws) {
283
- if (!ws || ws._clayPane) return;
283
+ if (!ws) return;
284
+ // Pane clients need the same owner-scoped pair roles as the parent shell.
285
+ // The Worker composer lock resolves its role from this projection; hiding
286
+ // the projection from panes leaves a configured Worker looking editable.
284
287
  ctx.sendTo(ws, { type: "split_groups", groups: store.listFor(ws) });
285
288
  }
286
289