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.
- package/lib/project-logs-mcp-server.js +14 -12
- package/lib/project-pair-lifecycle.js +12 -8
- package/lib/project-session-pair.js +37 -37
- package/lib/project-sessions.js +2 -1
- package/lib/project-user-message.js +1 -0
- package/lib/project-worker-permission.js +3 -2
- package/lib/project.js +2 -0
- package/lib/public/app.js +0 -2
- package/lib/public/css/project-logs.css +25 -79
- package/lib/public/modules/project-logs-render.js +2 -2
- package/lib/public/modules/project-logs.js +62 -59
- package/lib/public/modules/split-group-helpers.js +8 -0
- package/lib/public/modules/split-view.js +5 -2
- package/lib/public/modules/tools.js +21 -1
- package/lib/sdk-bridge.js +13 -19
- package/lib/sdk-message-processor.js +2 -3
- package/lib/server-home-chat.js +5 -0
- package/lib/session-pair-factory.js +11 -0
- package/lib/session-pair-mcp-server.js +2 -0
- package/lib/session-pair-prompts.js +3 -3
- package/lib/session-pair-turn-control.js +153 -0
- package/lib/session-split-groups.js +4 -1
- package/lib/session-title-policy.js +60 -0
- package/package.json +1 -1
- package/lib/public/modules/project-logs-ambient.js +0 -238
|
@@ -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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
256
|
-
// Opening the
|
|
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
|
|
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
|
-
|
|
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
|
|
313
|
-
// focuses, or scrolls
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
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
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
|
1724
|
-
//
|
|
1725
|
-
//
|
|
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 =
|
|
1722
|
+
session.titleAutoGenerated = !initialTitleProvisional;
|
|
1729
1723
|
sm.saveSessionFile(session);
|
|
1730
1724
|
sm.broadcastSessionList();
|
|
1731
1725
|
} else if (initialTitle && session.title === initialTitle) {
|
|
1732
|
-
session.titleAutoGenerated =
|
|
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 ===
|
|
627
|
+
if (session.turnCount === sessionTitlePolicy.generationTurn(isMate)
|
|
629
628
|
&& !session.titleAutoGenerated
|
|
630
629
|
&& !session.titleManuallySet
|
|
631
630
|
&& !session.loop
|
package/lib/server-home-chat.js
CHANGED
|
@@ -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.
|
|
27
|
-
"
|
|
28
|
-
"close_partner
|
|
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
|
|
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
|
|