clay-server 4.0.0-beta.2 → 4.0.0-beta.21
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/background-task-timing.js +105 -0
- package/lib/capsule-display-floor.js +51 -0
- package/lib/capsule-frame-server.js +255 -0
- package/lib/capsule-mate-turn.js +105 -0
- package/lib/capsule-pig-logic.js +300 -0
- package/lib/capsule-server-runtimes.js +39 -0
- package/lib/capsules/pig/display.js +198 -0
- package/lib/capsules/pig/manifest.json +9 -0
- package/lib/capsules/pig/ui.json +84 -0
- package/lib/config.js +25 -1
- package/lib/daemon-projects.js +13 -8
- package/lib/daemon.js +127 -14
- package/lib/git-cli.js +95 -1
- package/lib/knowledge-import.js +436 -0
- package/lib/knowledge-record-store.js +188 -0
- package/lib/knowledge-search.js +240 -0
- package/lib/mate-knowledge-mcp-server.js +151 -0
- package/lib/mate-knowledge-migration.js +283 -0
- package/lib/mate-knowledge-service.js +488 -0
- package/lib/mate-knowledge-sync.js +482 -0
- package/lib/migrate-single-user.js +2 -0
- package/lib/notes-lifecycle.js +149 -0
- package/lib/notes.js +60 -3
- package/lib/os-users.js +3 -1
- package/lib/project-capsule-catalog.js +9 -1
- package/lib/project-capsule-turn.js +96 -0
- package/lib/project-connection.js +14 -1
- package/lib/project-http.js +15 -1
- package/lib/project-knowledge.js +22 -0
- package/lib/project-log-context.js +172 -0
- package/lib/project-log-feedback-delivery.js +118 -0
- package/lib/project-logs-comments.js +137 -0
- package/lib/project-logs-context-state.js +42 -0
- package/lib/project-logs-mcp-server.js +328 -0
- package/lib/project-logs-query.js +185 -0
- package/lib/project-logs-root.js +75 -0
- package/lib/project-logs-schema.js +259 -0
- package/lib/project-logs-service.js +463 -0
- package/lib/project-logs-snapshot.js +205 -0
- package/lib/project-logs-store.js +495 -0
- package/lib/project-logs-versioning.js +132 -0
- package/lib/project-logs.js +400 -0
- package/lib/project-mate-interaction.js +14 -0
- package/lib/project-mate-knowledge.js +110 -0
- package/lib/project-memory.js +22 -0
- package/lib/project-message-delivery.js +70 -0
- package/lib/project-pair-lifecycle.js +460 -0
- package/lib/project-session-handoff.js +1 -1
- package/lib/project-session-notes.js +64 -13
- package/lib/project-session-pair.js +159 -146
- package/lib/project-session-spawn.js +1 -1
- package/lib/project-sessions.js +58 -13
- package/lib/project-user-message.js +53 -4
- package/lib/project-vendor-login.js +20 -3
- package/lib/project-worker-permission.js +430 -0
- package/lib/project-worker-proposal.js +208 -62
- package/lib/project.js +178 -6
- package/lib/public/app.js +58 -35
- package/lib/public/css/admin.css +2 -2
- package/lib/public/css/capsule-ui.css +38 -0
- package/lib/public/css/command-palette.css +19 -9
- package/lib/public/css/git-placard.css +154 -0
- package/lib/public/css/home-hub.css +4 -0
- package/lib/public/css/home-session-actions.css +66 -0
- package/lib/public/css/home-sidebar.css +66 -0
- package/lib/public/css/input.css +163 -28
- package/lib/public/css/mobile-nav.css +80 -0
- package/lib/public/css/notifications-center.css +67 -0
- package/lib/public/css/pane.css +52 -0
- package/lib/public/css/project-logs.css +198 -0
- package/lib/public/css/scheduler.css +1 -1
- package/lib/public/css/session-actions.css +16 -101
- package/lib/public/css/sidebar.css +103 -0
- package/lib/public/css/sticky-notes.css +237 -229
- package/lib/public/css/worker-proposal.css +34 -1
- package/lib/public/index.html +45 -11
- package/lib/public/modules/admin.js +8 -8
- package/lib/public/modules/app-connection.js +109 -4
- package/lib/public/modules/app-home-hub.js +19 -7
- package/lib/public/modules/app-message-cards.js +2 -1
- package/lib/public/modules/app-messages.js +84 -7
- package/lib/public/modules/app-misc.js +2 -14
- package/lib/public/modules/app-notifications.js +11 -7
- package/lib/public/modules/app-panels.js +46 -4
- package/lib/public/modules/app-projects.js +13 -8
- package/lib/public/modules/app-rendering.js +1 -0
- package/lib/public/modules/background-tasks-ui.js +139 -16
- package/lib/public/modules/capsule-preference.js +44 -0
- package/lib/public/modules/chat-bubble-renderer.js +1 -1
- package/lib/public/modules/clay-log-links.js +70 -0
- package/lib/public/modules/command-palette.js +52 -15
- package/lib/public/modules/context-sources.js +0 -3
- package/lib/public/modules/git-agent-sessions.js +85 -0
- package/lib/public/modules/git-panel.js +111 -84
- package/lib/public/modules/git-placard.js +161 -0
- package/lib/public/modules/home-chat-empty-state.js +39 -0
- package/lib/public/modules/home-chat-identity.js +9 -0
- package/lib/public/modules/home-conversations-sheet.js +102 -48
- package/lib/public/modules/home-mate-chat.js +15 -35
- package/lib/public/modules/home-session-actions.js +6 -0
- package/lib/public/modules/home-sidebar-chat-list.js +101 -41
- package/lib/public/modules/home-sidebar.js +7 -0
- package/lib/public/modules/home-tool-frame.js +182 -0
- package/lib/public/modules/home-tools.js +138 -1
- package/lib/public/modules/input.js +38 -7
- package/lib/public/modules/markdown.js +2 -0
- package/lib/public/modules/mate-sidebar.js +0 -8
- package/lib/public/modules/message-delivery.js +97 -0
- package/lib/public/modules/paste-modal.js +84 -0
- package/lib/public/modules/project-logs-render.js +405 -0
- package/lib/public/modules/project-logs.js +461 -0
- package/lib/public/modules/project-removal-target.js +26 -0
- package/lib/public/modules/scheduler.js +17 -3
- package/lib/public/modules/session-actions.js +29 -105
- package/lib/public/modules/session-hierarchy.js +54 -0
- package/lib/public/modules/sidebar-mobile.js +40 -15
- package/lib/public/modules/sidebar-session-hierarchy.js +214 -0
- package/lib/public/modules/sidebar-sessions.js +77 -11
- package/lib/public/modules/sidebar.js +17 -5
- package/lib/public/modules/split-group-helpers.js +8 -0
- package/lib/public/modules/split-view.js +5 -2
- package/lib/public/modules/sticky-note-markdown.js +5 -0
- package/lib/public/modules/sticky-notes-browser.js +326 -0
- package/lib/public/modules/sticky-notes-card.js +360 -0
- package/lib/public/modules/sticky-notes-editor.js +291 -0
- package/lib/public/modules/sticky-notes-shared.js +90 -0
- package/lib/public/modules/sticky-notes.js +170 -946
- package/lib/public/modules/tool-palette-order.js +143 -0
- package/lib/public/modules/tool-palette-overlays.js +159 -0
- package/lib/public/modules/tool-palette.js +21 -188
- package/lib/public/modules/tools.js +21 -1
- package/lib/public/modules/update-snooze.js +169 -0
- package/lib/public/modules/user-settings.js +4 -0
- package/lib/public/modules/worker-pane-lock.js +176 -0
- package/lib/public/modules/worker-proposal-state.js +16 -0
- package/lib/public/modules/worker-proposal.js +49 -12
- package/lib/public/style.css +3 -1
- package/lib/sdk-bridge.js +132 -46
- package/lib/sdk-message-processor.js +60 -14
- package/lib/server-experimental-settings.js +49 -0
- package/lib/server-global-ws.js +11 -0
- package/lib/server-home-chat-events.js +31 -1
- package/lib/server-home-chat.js +27 -4
- package/lib/server-home-clay-entry.js +1 -1
- package/lib/server-home-clay-session-links.js +34 -5
- package/lib/server-settings.js +5 -0
- package/lib/server-tools.js +127 -9
- package/lib/server.js +124 -18
- package/lib/session-driver-eligibility.js +38 -0
- package/lib/session-driver-orchestration.js +31 -0
- package/lib/session-hygiene.js +3 -0
- package/lib/session-notes-mcp-server.js +25 -5
- package/lib/session-pair-factory.js +247 -0
- package/lib/session-pair-mcp-server.js +46 -8
- package/lib/session-pair-prompts.js +79 -0
- package/lib/session-pair-turn-control.js +153 -0
- package/lib/session-provenance.js +119 -0
- package/lib/session-spawn-mcp-server.js +1 -1
- package/lib/session-split-groups.js +4 -1
- package/lib/session-title-policy.js +60 -0
- package/lib/session-visibility.js +39 -0
- package/lib/sessions.js +61 -11
- package/lib/tools-registry.js +89 -10
- package/lib/update-snooze.js +437 -0
- package/lib/users-experimental-preferences.js +30 -0
- package/lib/users-permissions.js +2 -2
- package/lib/users.js +22 -16
- package/lib/workspace-query-access.js +102 -0
- package/lib/workspace-query-service.js +24 -17
- package/lib/worktree.js +25 -38
- package/lib/ws-schema.js +38 -7
- package/lib/yoke/adapters/claude-worker.js +16 -6
- package/lib/yoke/adapters/claude.js +81 -4
- package/lib/yoke/adapters/codex.js +63 -15
- package/lib/yoke/codex-background-tasks.js +8 -2
- package/lib/yoke/vendor-registry.js +1 -1
- package/package.json +3 -3
|
@@ -34,12 +34,15 @@ function attachUserMessage(ctx) {
|
|
|
34
34
|
var sm = ctx.sm;
|
|
35
35
|
var sdk = ctx.sdk;
|
|
36
36
|
var nm = ctx.nm;
|
|
37
|
+
var notesLifecycle = require("./notes-lifecycle");
|
|
38
|
+
var createProjectMessageDelivery = require("./project-message-delivery").createProjectMessageDelivery;
|
|
37
39
|
var tm = ctx.tm;
|
|
38
40
|
|
|
39
41
|
var send = ctx.send;
|
|
40
42
|
var sendTo = ctx.sendTo;
|
|
41
43
|
var sendToSession = ctx.sendToSession;
|
|
42
44
|
var sendToSessionOthers = ctx.sendToSessionOthers;
|
|
45
|
+
var messageDelivery = createProjectMessageDelivery(sendTo);
|
|
43
46
|
|
|
44
47
|
var clients = ctx.clients;
|
|
45
48
|
var opts = ctx.opts;
|
|
@@ -108,7 +111,13 @@ function attachUserMessage(ctx) {
|
|
|
108
111
|
|
|
109
112
|
if (msg.type === "note_update") {
|
|
110
113
|
if (!msg.id) return true;
|
|
111
|
-
|
|
114
|
+
// A legacy `hidden` update is a lifecycle transition, so the actor is
|
|
115
|
+
// derived from the bound socket rather than taken from the message.
|
|
116
|
+
var noteChanges = msg;
|
|
117
|
+
if (msg.hidden !== undefined) {
|
|
118
|
+
noteChanges = Object.assign({}, msg, { actor: notesLifecycle.userActor(ws && ws._clayUser) });
|
|
119
|
+
}
|
|
120
|
+
var updated = nm.update(msg.id, noteChanges);
|
|
112
121
|
if (updated) {
|
|
113
122
|
send({ type: "note_updated", note: updated });
|
|
114
123
|
if (msg.text !== undefined || msg.hidden !== undefined) syncNotesKnowledge();
|
|
@@ -116,10 +125,24 @@ function attachUserMessage(ctx) {
|
|
|
116
125
|
return true;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
|
-
|
|
128
|
+
// Completing a note closes it. `note_delete` is the retired spelling and is
|
|
129
|
+
// deliberately routed to the same non-destructive close, so an older client
|
|
130
|
+
// still on the wire cannot erase a record.
|
|
131
|
+
if (msg.type === "note_close" || msg.type === "note_delete") {
|
|
132
|
+
if (!msg.id) return true;
|
|
133
|
+
var closed = nm.close(msg.id, notesLifecycle.userActor(ws && ws._clayUser));
|
|
134
|
+
if (closed) {
|
|
135
|
+
send({ type: "note_updated", note: closed });
|
|
136
|
+
syncNotesKnowledge();
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (msg.type === "note_reopen") {
|
|
120
142
|
if (!msg.id) return true;
|
|
121
|
-
|
|
122
|
-
|
|
143
|
+
var reopened = nm.reopen(msg.id);
|
|
144
|
+
if (reopened) {
|
|
145
|
+
send({ type: "note_updated", note: reopened });
|
|
123
146
|
syncNotesKnowledge();
|
|
124
147
|
}
|
|
125
148
|
return true;
|
|
@@ -294,6 +317,29 @@ function attachUserMessage(ctx) {
|
|
|
294
317
|
|
|
295
318
|
var session = getSessionForWs(ws);
|
|
296
319
|
if (!session) return true;
|
|
320
|
+
|
|
321
|
+
var deliveryReceipt = messageDelivery.inspect(ws, session, msg);
|
|
322
|
+
if (deliveryReceipt.duplicate) {
|
|
323
|
+
messageDelivery.acknowledge(ws, deliveryReceipt, hydrateImageRefs(deliveryReceipt.recordedMessage));
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// A configured Split Worker is Driver-operated: its work arrives through
|
|
328
|
+
// the Driver's delegation, not from a person typing into its pane. The UI
|
|
329
|
+
// hides the composer, but the refusal has to live here too — a legacy
|
|
330
|
+
// client, a replayed frame or a hand-crafted message must not be able to
|
|
331
|
+
// inject a human turn into it. The role comes from exact live pair state,
|
|
332
|
+
// never from the payload, and this is the ordinary-message path only:
|
|
333
|
+
// delegated Driver messages, Driver permission decisions, tool traffic and
|
|
334
|
+
// the user's stop/close controls all travel other paths and are untouched.
|
|
335
|
+
if (typeof ctx.isDriverOperatedSession === "function" && ctx.isDriverOperatedSession(session)) {
|
|
336
|
+
sendTo(ws, {
|
|
337
|
+
type: "error",
|
|
338
|
+
text: "This Split Worker is controlled by its Driver. Send the instruction to the Driver session instead.",
|
|
339
|
+
});
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
if (typeof ctx.beginHumanPairTurn === "function") ctx.beginHumanPairTurn(session);
|
|
297
343
|
if (gitAttribution) gitAttribution.beginTurn(session);
|
|
298
344
|
|
|
299
345
|
// Bind vendor to session on first message (if not already set)
|
|
@@ -337,9 +383,12 @@ function attachUserMessage(ctx) {
|
|
|
337
383
|
if (msg.pastes && msg.pastes.length > 0) {
|
|
338
384
|
userMsg2.pastes = msg.pastes;
|
|
339
385
|
}
|
|
386
|
+
if (deliveryReceipt.clientMessageId) userMsg2.clientMessageId = deliveryReceipt.clientMessageId;
|
|
340
387
|
if (!userMsg2._ts) userMsg2._ts = Date.now();
|
|
341
388
|
session.history.push(userMsg2);
|
|
342
389
|
sm.appendToSessionFile(session, userMsg2);
|
|
390
|
+
messageDelivery.markRecorded(session, deliveryReceipt, userMsg2);
|
|
391
|
+
messageDelivery.acknowledge(ws, deliveryReceipt, hydrateImageRefs(userMsg2));
|
|
343
392
|
sendToSessionOthers(ws, session.localId, hydrateImageRefs(userMsg2));
|
|
344
393
|
|
|
345
394
|
if (!session.title) {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// auth_required forever.
|
|
21
21
|
|
|
22
22
|
var vendorRegistry = require("./yoke/vendor-registry");
|
|
23
|
+
var yoke = require("./yoke");
|
|
23
24
|
|
|
24
25
|
// Login CLIs print a success line and then hand the shell back, so the PTY
|
|
25
26
|
// itself does not exit. Watch the output stream instead. Deliberately narrow:
|
|
@@ -62,7 +63,8 @@ function looksLikeLoginSuccess(text) {
|
|
|
62
63
|
* sm, tm, adapters,
|
|
63
64
|
* send, sendTo,
|
|
64
65
|
* usersModule,
|
|
65
|
-
* getOsUserInfoForWs, getOsUserInfoForLinuxUser, getLinuxUserForSession
|
|
66
|
+
* getOsUserInfoForWs, getOsUserInfoForLinuxUser, getLinuxUserForSession,
|
|
67
|
+
* refreshVendorAuthEverywhere
|
|
66
68
|
*/
|
|
67
69
|
function attachVendorLogin(ctx) {
|
|
68
70
|
var slug = ctx.slug;
|
|
@@ -80,6 +82,7 @@ function attachVendorLogin(ctx) {
|
|
|
80
82
|
var getOsUserInfoForWs = ctx.getOsUserInfoForWs;
|
|
81
83
|
var getOsUserInfoForLinuxUser = ctx.getOsUserInfoForLinuxUser;
|
|
82
84
|
var getLinuxUserForSession = ctx.getLinuxUserForSession;
|
|
85
|
+
var refreshVendorAuthEverywhere = ctx.refreshVendorAuthEverywhere;
|
|
83
86
|
|
|
84
87
|
// vendor -> flow record. At most one live login terminal per vendor.
|
|
85
88
|
var _flows = Object.create(null);
|
|
@@ -181,13 +184,17 @@ function attachVendorLogin(ctx) {
|
|
|
181
184
|
// the credentials the login just wrote. Shared adapter instances (Claude) are
|
|
182
185
|
// reused by every project, so tearing one down here would kill unrelated
|
|
183
186
|
// sessions; those runtimes pick up new credentials on their own.
|
|
184
|
-
function refreshVendorAuth(vendor) {
|
|
187
|
+
function refreshVendorAuth(vendor, linuxUser) {
|
|
185
188
|
var adapter = adapters[vendor];
|
|
186
189
|
if (!adapter || typeof adapter.shutdown !== "function") return Promise.resolve(false);
|
|
187
190
|
if (adapter.shared) {
|
|
188
191
|
console.log("[vendor-login] " + vendor + " adapter is shared; skipping restart for " + slug);
|
|
189
192
|
return Promise.resolve(false);
|
|
190
193
|
}
|
|
194
|
+
if (typeof adapter.refreshAuthIdentity === "function") {
|
|
195
|
+
return Promise.resolve(adapter.refreshAuthIdentity(linuxUser || null));
|
|
196
|
+
}
|
|
197
|
+
if (linuxUser) return Promise.resolve(false);
|
|
191
198
|
// adapter.shutdown() already fans out to every per-OS-user runtime it
|
|
192
199
|
// created (shutdownUserRuntimes), so one call covers isolated setups too.
|
|
193
200
|
return Promise.resolve()
|
|
@@ -209,7 +216,16 @@ function attachVendorLogin(ctx) {
|
|
|
209
216
|
flow.finishing = true;
|
|
210
217
|
flow.completed = !!succeeded;
|
|
211
218
|
|
|
212
|
-
|
|
219
|
+
// The next session must not reuse the pre-login "not authenticated"
|
|
220
|
+
// result. Re-check before restarting the runtime, which will then read
|
|
221
|
+
// the newly written credential file.
|
|
222
|
+
if (succeeded) yoke.invalidateAuthCache();
|
|
223
|
+
var refresh = Promise.resolve(false);
|
|
224
|
+
if (succeeded) {
|
|
225
|
+
refresh = typeof refreshVendorAuthEverywhere === "function"
|
|
226
|
+
? refreshVendorAuthEverywhere(vendor, flow.linuxUser || null)
|
|
227
|
+
: refreshVendorAuth(vendor, flow.linuxUser || null);
|
|
228
|
+
}
|
|
213
229
|
refresh.then(function (restarted) {
|
|
214
230
|
discardFlow(vendor);
|
|
215
231
|
closeFlowTerminal(flow);
|
|
@@ -391,6 +407,7 @@ function attachVendorLogin(ctx) {
|
|
|
391
407
|
listFlows: listFlows,
|
|
392
408
|
forgetTerminal: forgetTerminal,
|
|
393
409
|
isLoginTerminal: isLoginTerminal,
|
|
410
|
+
refreshVendorAuth: refreshVendorAuth,
|
|
394
411
|
};
|
|
395
412
|
}
|
|
396
413
|
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// Split Worker permission routing.
|
|
2
|
+
//
|
|
3
|
+
// A visible Split Worker runs work the human delegated to its Driver, not to
|
|
4
|
+
// itself. So an ordinary provider permission prompt raised inside the Worker is
|
|
5
|
+
// answered by its exact paired Driver through the same MCP tool coordination
|
|
6
|
+
// the pair already uses, instead of being presented in the Worker pane as if
|
|
7
|
+
// the human had asked for that tool call directly.
|
|
8
|
+
//
|
|
9
|
+
// Boundaries this module does NOT cross:
|
|
10
|
+
//
|
|
11
|
+
// - It sits BELOW every existing auto-decision in handleCanUseTool. Skip
|
|
12
|
+
// permissions (bypassPermissions), the read-only/safe-bash whitelist,
|
|
13
|
+
// session allowedTools, Ralph loop denials and the debate tool policy all
|
|
14
|
+
// resolve before routing is even considered, so enabling skip permissions
|
|
15
|
+
// on the Worker passes questions through the established bypass and never
|
|
16
|
+
// waits on the Driver. There is no second toggle.
|
|
17
|
+
// - USER_INPUT_TOOLS still go to the human, because they are not permission
|
|
18
|
+
// approvals at all. AskUserQuestion is a question addressed to the person;
|
|
19
|
+
// answering it is content, not authorization. Note that Clay already
|
|
20
|
+
// exempts it from bypassPermissions for the same reason.
|
|
21
|
+
// - Everything else, including the plan-mode tools, is a permission approval
|
|
22
|
+
// and goes to the Driver. A Driver owns the Worker's execution mode within
|
|
23
|
+
// the scope the user delegated, so deciding EnterPlanMode/ExitPlanMode for
|
|
24
|
+
// it is in scope. See the note on plan-mode decision metadata below.
|
|
25
|
+
// - An approval answers exactly one request. It never writes
|
|
26
|
+
// session.allowedTools and never changes a permission mode, so it cannot
|
|
27
|
+
// pre-authorize the Worker's next call.
|
|
28
|
+
//
|
|
29
|
+
// Plan-mode decision metadata: the human plan card can answer ExitPlanMode
|
|
30
|
+
// with "allow_accept_edits" or "allow_clear_context", which flip
|
|
31
|
+
// sm.currentPermissionMode / start a fresh session. Those are *decision
|
|
32
|
+
// strings* carried on the permission_response WebSocket message and are
|
|
33
|
+
// interpreted only in project-sessions.js — they are not tool input, and they
|
|
34
|
+
// are structurally unreachable from here because the router resolves the
|
|
35
|
+
// provider callback directly and never travels that path. A Driver's approval
|
|
36
|
+
// is therefore exactly the plain "allow" the card's own "Manually approve"
|
|
37
|
+
// button sends: the plan is accepted, the provider leaves plan mode, and no
|
|
38
|
+
// session-global state changes.
|
|
39
|
+
//
|
|
40
|
+
// Identity is always re-derived from the live pair record, never from the
|
|
41
|
+
// caller's arguments: a response must come from the exact Driver of the exact
|
|
42
|
+
// group that still pairs the exact Worker the request came from, owned by the
|
|
43
|
+
// same user. Anything else is refused and the request stays pending.
|
|
44
|
+
|
|
45
|
+
var crypto = require("crypto");
|
|
46
|
+
|
|
47
|
+
// Tools that collect input from the person rather than authorize an action.
|
|
48
|
+
// These are not permission approvals, so they are never routed to a Driver;
|
|
49
|
+
// a Worker asking the user a question still asks the user.
|
|
50
|
+
var USER_INPUT_TOOLS = ["AskUserQuestion"];
|
|
51
|
+
|
|
52
|
+
var DECISION_TIMEOUT_MS = 5 * 60 * 1000;
|
|
53
|
+
var LIVENESS_INTERVAL_MS = 2000;
|
|
54
|
+
var MAX_INPUT_PREVIEW_CHARS = 2000;
|
|
55
|
+
|
|
56
|
+
function isUserInputTool(toolName) {
|
|
57
|
+
return USER_INPUT_TOOLS.indexOf(toolName) !== -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function previewInput(input) {
|
|
61
|
+
var text;
|
|
62
|
+
try {
|
|
63
|
+
text = JSON.stringify(input === undefined ? null : input);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return "(tool input could not be serialized)";
|
|
66
|
+
}
|
|
67
|
+
if (typeof text !== "string") return "(no tool input)";
|
|
68
|
+
if (text.length > MAX_INPUT_PREVIEW_CHARS) text = text.slice(0, MAX_INPUT_PREVIEW_CHARS) + "…(truncated)";
|
|
69
|
+
return text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function attachWorkerPermission(ctx) {
|
|
73
|
+
var sm = ctx.sm;
|
|
74
|
+
var store = ctx.splitStore;
|
|
75
|
+
var resumeDriver = ctx.resumeDriverWithMessage;
|
|
76
|
+
var requestDetach = ctx.requestDetach || function () { return false; };
|
|
77
|
+
|
|
78
|
+
// requestId -> record. The only place a routed request lives.
|
|
79
|
+
var pending = Object.create(null);
|
|
80
|
+
|
|
81
|
+
function toolResult(value) {
|
|
82
|
+
return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toolError(message) {
|
|
86
|
+
return Promise.resolve({
|
|
87
|
+
content: [{ type: "text", text: "Error: " + message }],
|
|
88
|
+
isError: true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The live pair as the store sees it right now. Returns null unless this
|
|
93
|
+
// session is the configured Worker of a pair whose Driver still exists and
|
|
94
|
+
// belongs to the same owner.
|
|
95
|
+
function resolveWorkerPair(session) {
|
|
96
|
+
if (!session || !store) return null;
|
|
97
|
+
// Exact live object identity, before the group is consulted. A localId is
|
|
98
|
+
// reusable and a session object can be replaced by a rehydrated or forged
|
|
99
|
+
// one carrying the same id, so identity by id alone would let a stale
|
|
100
|
+
// object inherit the Driver's permission mode, count as Driver-operated,
|
|
101
|
+
// or open a routed request against the wrong object. Every caller of this
|
|
102
|
+
// function — routing, isDriverOperated, inheritedPermissionMode, and so the
|
|
103
|
+
// human-send refusal — is protected by this one check.
|
|
104
|
+
if (sm.sessions.get(session.localId) !== session) return null;
|
|
105
|
+
var group = store.groupForMember(session.localId);
|
|
106
|
+
if (!group || !group.pair) return null;
|
|
107
|
+
if (group.pair.workerId !== session.localId) return null;
|
|
108
|
+
var driver = sm.sessions.get(group.pair.driverId);
|
|
109
|
+
if (!driver) return null;
|
|
110
|
+
if ((driver.ownerId || null) !== (session.ownerId || null)) return null;
|
|
111
|
+
if (driver.destroying) return null;
|
|
112
|
+
return { group: group, driver: driver };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Is the record's pair still exactly the pair it was created for?
|
|
116
|
+
function pairStillExact(record) {
|
|
117
|
+
var group = store ? store.groupForMember(record.workerId) : null;
|
|
118
|
+
if (!group || group.id !== record.groupId || !group.pair) return false;
|
|
119
|
+
if (group.pair.driverId !== record.driverId) return false;
|
|
120
|
+
if (group.pair.workerId !== record.workerId) return false;
|
|
121
|
+
var driver = sm.sessions.get(record.driverId);
|
|
122
|
+
var worker = sm.sessions.get(record.workerId);
|
|
123
|
+
if (!driver || !worker || driver.destroying || worker.destroying) return false;
|
|
124
|
+
// The exact objects the request was opened against. If either side has
|
|
125
|
+
// been replaced in the manager — a restart, a rehydration, an impostor
|
|
126
|
+
// reusing the id — this is no longer the same pair, so the request fails
|
|
127
|
+
// closed rather than resolving against a different session.
|
|
128
|
+
if (driver !== record.driverRef || worker !== record.workerRef) return false;
|
|
129
|
+
if ((driver.ownerId || null) !== record.ownerId) return false;
|
|
130
|
+
if ((worker.ownerId || null) !== record.ownerId) return false;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function clearRecord(record) {
|
|
135
|
+
if (record.timer) {
|
|
136
|
+
clearInterval(record.timer);
|
|
137
|
+
record.timer = null;
|
|
138
|
+
}
|
|
139
|
+
delete pending[record.requestId];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Every exit path funnels through here, so a request is answered exactly
|
|
143
|
+
// once and its timer never outlives it.
|
|
144
|
+
function settle(record, outcome) {
|
|
145
|
+
if (record.settled) return false;
|
|
146
|
+
record.settled = true;
|
|
147
|
+
clearRecord(record);
|
|
148
|
+
if (outcome.behavior === "allow") {
|
|
149
|
+
record.resolve({ behavior: "allow", updatedInput: record.toolInput });
|
|
150
|
+
} else {
|
|
151
|
+
record.resolve({ behavior: "deny", message: outcome.message || "Denied by the paired Driver" });
|
|
152
|
+
}
|
|
153
|
+
if (typeof ctx.onProcessingChanged === "function") ctx.onProcessingChanged();
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Fail closed. Used for timeout, pair loss, Driver loss and Worker stop:
|
|
158
|
+
// a request never leaks to another session and never hangs forever.
|
|
159
|
+
//
|
|
160
|
+
// No frame is emitted to either pane. Model coordination is complete without
|
|
161
|
+
// one: the Worker learns the outcome because its own tool call resolves, and
|
|
162
|
+
// a Driver that answers a request that has since died gets "already_resolved"
|
|
163
|
+
// from the tool. Adding a frame would mean adding client rendering, which
|
|
164
|
+
// this change deliberately does not do.
|
|
165
|
+
function failClosed(record, reason) {
|
|
166
|
+
settle(record, { behavior: "deny", message: reason });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function startLiveness(record) {
|
|
170
|
+
record.timer = setInterval(function () {
|
|
171
|
+
if (record.settled) {
|
|
172
|
+
clearRecord(record);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!pairStillExact(record)) {
|
|
176
|
+
failClosed(record, "The Driver/Split Worker pair changed before the request was answered.");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (Date.now() >= record.deadline) {
|
|
180
|
+
failClosed(record, "The paired Driver did not answer this permission request in time.");
|
|
181
|
+
}
|
|
182
|
+
}, LIVENESS_INTERVAL_MS);
|
|
183
|
+
if (record.timer && typeof record.timer.unref === "function") record.timer.unref();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function driverRequestText(record) {
|
|
187
|
+
return "[Split Worker permission request]\n" +
|
|
188
|
+
"The Split Worker you delegated to needs approval for one tool call before it can continue. " +
|
|
189
|
+
"You are deciding on behalf of the task the user already authorized: approve it only if it is " +
|
|
190
|
+
"within that scope, and deny it otherwise.\n\n" +
|
|
191
|
+
"Request id: " + record.requestId + "\n" +
|
|
192
|
+
"Tool: " + record.toolName + "\n" +
|
|
193
|
+
"Tool input: " + previewInput(record.toolInput) + "\n" +
|
|
194
|
+
(record.decisionReason ? "Provider note: " + record.decisionReason + "\n" : "") +
|
|
195
|
+
"\nAnswer with respond_to_worker_permission using exactly this request id. " +
|
|
196
|
+
"This decision covers only this one call; the Split Worker will ask again for the next one.";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Called from handleCanUseTool after every existing auto-decision. Returns a
|
|
200
|
+
// Promise when the request was routed to a Driver, or null to let the normal
|
|
201
|
+
// human-facing flow run.
|
|
202
|
+
function routeIfWorker(session, req) {
|
|
203
|
+
if (!req) return null;
|
|
204
|
+
if (isUserInputTool(req.toolName)) return null;
|
|
205
|
+
var resolved = resolveWorkerPair(session);
|
|
206
|
+
if (!resolved) return null;
|
|
207
|
+
|
|
208
|
+
var record = {
|
|
209
|
+
requestId: "wperm_" + crypto.randomUUID(),
|
|
210
|
+
groupId: resolved.group.id,
|
|
211
|
+
driverId: resolved.driver.localId,
|
|
212
|
+
workerId: session.localId,
|
|
213
|
+
driverRef: resolved.driver,
|
|
214
|
+
workerRef: session,
|
|
215
|
+
ownerId: session.ownerId || null,
|
|
216
|
+
toolName: req.toolName,
|
|
217
|
+
toolInput: req.input,
|
|
218
|
+
toolUseId: req.toolUseId || null,
|
|
219
|
+
decisionReason: req.decisionReason || "",
|
|
220
|
+
resolve: null,
|
|
221
|
+
settled: false,
|
|
222
|
+
createdAt: Date.now(),
|
|
223
|
+
deadline: Date.now() + DECISION_TIMEOUT_MS,
|
|
224
|
+
timer: null,
|
|
225
|
+
};
|
|
226
|
+
// The executor runs synchronously, so `resolve` is in place before the
|
|
227
|
+
// record is registered and before anything can settle it.
|
|
228
|
+
var decided = new Promise(function (resolve) { record.resolve = resolve; });
|
|
229
|
+
pending[record.requestId] = record;
|
|
230
|
+
|
|
231
|
+
// The Worker being stopped, or its query aborting, cancels the request
|
|
232
|
+
// rather than leaving the Driver holding a decision nobody needs.
|
|
233
|
+
if (req.signal && typeof req.signal.addEventListener === "function") {
|
|
234
|
+
req.signal.addEventListener("abort", function () {
|
|
235
|
+
var live = pending[record.requestId];
|
|
236
|
+
if (live) failClosed(live, "The Split Worker stopped before the request was answered.");
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A Driver blocked inside a waiting send_to_partner cannot call a tool, so
|
|
241
|
+
// its wait is detached first. The delegation then completes through the
|
|
242
|
+
// existing detached push-back path.
|
|
243
|
+
requestDetach(session);
|
|
244
|
+
|
|
245
|
+
startLiveness(record);
|
|
246
|
+
|
|
247
|
+
var delivered = false;
|
|
248
|
+
try {
|
|
249
|
+
delivered = resumeDriver(resolved.driver, driverRequestText(record), {
|
|
250
|
+
workerPermissionRequest: true,
|
|
251
|
+
workerSessionId: record.workerId,
|
|
252
|
+
requestId: record.requestId,
|
|
253
|
+
});
|
|
254
|
+
} catch (e) {
|
|
255
|
+
delivered = false;
|
|
256
|
+
}
|
|
257
|
+
if (!delivered) {
|
|
258
|
+
failClosed(record, "The paired Driver could not be reached to decide this request.");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return decided;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The Driver-side MCP tool. Every field is re-validated against the live
|
|
265
|
+
// pair; nothing about identity is taken from `args`.
|
|
266
|
+
function handleDriverResponse(args, caller) {
|
|
267
|
+
if (!caller) return toolError("respond_to_worker_permission requires a session-bound tool server");
|
|
268
|
+
var requestId = args && typeof args.requestId === "string" ? args.requestId.trim() : "";
|
|
269
|
+
if (!requestId) return toolError("requestId is required");
|
|
270
|
+
|
|
271
|
+
var record = pending[requestId];
|
|
272
|
+
if (!record || record.settled) {
|
|
273
|
+
// Idempotent: a duplicate or late answer is reported, never re-applied.
|
|
274
|
+
return toolResult({
|
|
275
|
+
status: "already_resolved",
|
|
276
|
+
requestId: requestId,
|
|
277
|
+
detail: "This permission request is no longer pending. It was answered, cancelled, or it expired.",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Exact Driver only. A different session in the same group, another
|
|
282
|
+
// group's Driver, or the Worker itself cannot answer.
|
|
283
|
+
if (record.driverId !== caller.localId) {
|
|
284
|
+
return toolError("only the paired Driver of that Split Worker can answer this request");
|
|
285
|
+
}
|
|
286
|
+
// And the exact live object. A tool handler captured by a query outlives
|
|
287
|
+
// the session it bound to, so a stale Driver object — or an impostor with
|
|
288
|
+
// the same localId — cannot answer through it. This refusal leaves the
|
|
289
|
+
// request pending on purpose: a genuinely live Driver may still answer it.
|
|
290
|
+
// The separate case where the live entry is no longer the object the
|
|
291
|
+
// request was opened against is a pair change, and pairStillExact below
|
|
292
|
+
// both detects it (via driverRef) and fails the request closed.
|
|
293
|
+
if (sm.sessions.get(caller.localId) !== caller) {
|
|
294
|
+
return toolError("this Driver session is no longer live; the request is bound to an exact session");
|
|
295
|
+
}
|
|
296
|
+
if ((caller.ownerId || null) !== record.ownerId) {
|
|
297
|
+
return toolError("permission request access denied");
|
|
298
|
+
}
|
|
299
|
+
if (!pairStillExact(record)) {
|
|
300
|
+
failClosed(record, "The Driver/Split Worker pair changed before the request was answered.");
|
|
301
|
+
return toolError("the Driver/Split Worker pair changed; this request is no longer valid");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
var decision = args && typeof args.decision === "string" ? args.decision.trim().toLowerCase() : "";
|
|
305
|
+
if (decision !== "allow" && decision !== "deny") {
|
|
306
|
+
return toolError('decision must be exactly "allow" or "deny"');
|
|
307
|
+
}
|
|
308
|
+
var reason = args && typeof args.reason === "string" ? args.reason.trim().slice(0, 500) : "";
|
|
309
|
+
|
|
310
|
+
if (decision === "allow") {
|
|
311
|
+
settle(record, { behavior: "allow" });
|
|
312
|
+
} else {
|
|
313
|
+
settle(record, {
|
|
314
|
+
behavior: "deny",
|
|
315
|
+
message: reason
|
|
316
|
+
? "Denied by the paired Driver: " + reason
|
|
317
|
+
: "Denied by the paired Driver.",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return toolResult({
|
|
322
|
+
status: "resolved",
|
|
323
|
+
requestId: record.requestId,
|
|
324
|
+
decision: decision,
|
|
325
|
+
workerSessionId: record.workerId,
|
|
326
|
+
detail: "This decision covered only that one tool call.",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Exposed to the Driver only, and only while it really is a pair Driver.
|
|
331
|
+
function getToolDefs(boundSession, options) {
|
|
332
|
+
if (!boundSession || !store) return [];
|
|
333
|
+
var group = store.groupForMember(boundSession.localId);
|
|
334
|
+
var dormantDriver = !!(options && options.dormantDriver);
|
|
335
|
+
if (!dormantDriver && (!group || !group.pair || group.pair.driverId !== boundSession.localId)) return [];
|
|
336
|
+
return [{
|
|
337
|
+
name: "respond_to_worker_permission",
|
|
338
|
+
description: "Approve or deny one tool-permission request raised by your paired Split Worker. " +
|
|
339
|
+
"Clay delivers each request to you with its request id. Decide within the task the user already " +
|
|
340
|
+
"authorized: approve only calls that belong to that scope, and deny anything outside it. " +
|
|
341
|
+
"A decision covers exactly one tool call and grants the Split Worker nothing further.",
|
|
342
|
+
inputSchema: {
|
|
343
|
+
type: "object",
|
|
344
|
+
properties: {
|
|
345
|
+
requestId: { type: "string", description: "The exact request id Clay delivered." },
|
|
346
|
+
decision: { type: "string", description: 'Either "allow" or "deny".' },
|
|
347
|
+
reason: { type: "string", description: "Optional short explanation, shown to the Split Worker on a denial." },
|
|
348
|
+
},
|
|
349
|
+
required: ["requestId", "decision"],
|
|
350
|
+
additionalProperties: false,
|
|
351
|
+
},
|
|
352
|
+
handler: function (toolArgs) { return handleDriverResponse(toolArgs || {}, boundSession); },
|
|
353
|
+
}];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Cancel everything tied to a session, in either role. Used when a session
|
|
357
|
+
// is torn down or a pair is dissolved.
|
|
358
|
+
function cancelForSession(session, reason) {
|
|
359
|
+
if (!session) return 0;
|
|
360
|
+
var ids = Object.keys(pending);
|
|
361
|
+
var cancelled = 0;
|
|
362
|
+
for (var i = 0; i < ids.length; i++) {
|
|
363
|
+
var record = pending[ids[i]];
|
|
364
|
+
if (!record) continue;
|
|
365
|
+
if (record.workerId !== session.localId && record.driverId !== session.localId) continue;
|
|
366
|
+
failClosed(record, reason || "The Driver/Split Worker pair is no longer available.");
|
|
367
|
+
cancelled++;
|
|
368
|
+
}
|
|
369
|
+
return cancelled;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// --- Driver-operated Worker surface -------------------------------------
|
|
373
|
+
|
|
374
|
+
// Is this session the configured Split Worker of a live pair? The single
|
|
375
|
+
// server-side answer for "is this session Driver-operated", used by the
|
|
376
|
+
// permission-mode inheritance below and by the human-send refusal. An
|
|
377
|
+
// ad-hoc side-by-side split has no pair roles and is never Driver-operated.
|
|
378
|
+
function driverOperatedPair(session) {
|
|
379
|
+
var resolved = resolveWorkerPair(session);
|
|
380
|
+
if (!resolved) return null;
|
|
381
|
+
return { group: resolved.group, driver: resolved.driver };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isDriverOperated(session) {
|
|
385
|
+
return !!driverOperatedPair(session);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The permission mode a configured Worker runs under: its Driver's, resolved
|
|
389
|
+
// fresh on every call. Never a copy taken at creation, and never written back
|
|
390
|
+
// onto the Worker, so flipping skip permissions on the Driver takes effect on
|
|
391
|
+
// the Worker's very next permission decision — including mid-turn — and
|
|
392
|
+
// flipping it back restores routing just as immediately.
|
|
393
|
+
//
|
|
394
|
+
// Returns null when the session is not a configured Worker, so the caller
|
|
395
|
+
// keeps its own ordinary resolution.
|
|
396
|
+
function inheritedPermissionMode(session) {
|
|
397
|
+
var resolved = driverOperatedPair(session);
|
|
398
|
+
if (!resolved) return null;
|
|
399
|
+
var driver = resolved.driver;
|
|
400
|
+
return driver.permissionMode || sm.currentPermissionMode || "default";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function pendingCountFor(session) {
|
|
404
|
+
if (!session) return 0;
|
|
405
|
+
var ids = Object.keys(pending);
|
|
406
|
+
var count = 0;
|
|
407
|
+
for (var i = 0; i < ids.length; i++) {
|
|
408
|
+
var record = pending[ids[i]];
|
|
409
|
+
if (record && !record.settled && record.workerId === session.localId) count++;
|
|
410
|
+
}
|
|
411
|
+
return count;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
USER_INPUT_TOOLS: USER_INPUT_TOOLS,
|
|
416
|
+
cancelForSession: cancelForSession,
|
|
417
|
+
getToolDefs: getToolDefs,
|
|
418
|
+
inheritedPermissionMode: inheritedPermissionMode,
|
|
419
|
+
isDriverOperated: isDriverOperated,
|
|
420
|
+
handleDriverResponse: handleDriverResponse,
|
|
421
|
+
pendingCountFor: pendingCountFor,
|
|
422
|
+
routeIfWorker: routeIfWorker,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
module.exports = {
|
|
427
|
+
attachWorkerPermission: attachWorkerPermission,
|
|
428
|
+
USER_INPUT_TOOLS: USER_INPUT_TOOLS,
|
|
429
|
+
isUserInputTool: isUserInputTool,
|
|
430
|
+
};
|