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.
Files changed (177) hide show
  1. package/lib/background-task-timing.js +105 -0
  2. package/lib/capsule-display-floor.js +51 -0
  3. package/lib/capsule-frame-server.js +255 -0
  4. package/lib/capsule-mate-turn.js +105 -0
  5. package/lib/capsule-pig-logic.js +300 -0
  6. package/lib/capsule-server-runtimes.js +39 -0
  7. package/lib/capsules/pig/display.js +198 -0
  8. package/lib/capsules/pig/manifest.json +9 -0
  9. package/lib/capsules/pig/ui.json +84 -0
  10. package/lib/config.js +25 -1
  11. package/lib/daemon-projects.js +13 -8
  12. package/lib/daemon.js +127 -14
  13. package/lib/git-cli.js +95 -1
  14. package/lib/knowledge-import.js +436 -0
  15. package/lib/knowledge-record-store.js +188 -0
  16. package/lib/knowledge-search.js +240 -0
  17. package/lib/mate-knowledge-mcp-server.js +151 -0
  18. package/lib/mate-knowledge-migration.js +283 -0
  19. package/lib/mate-knowledge-service.js +488 -0
  20. package/lib/mate-knowledge-sync.js +482 -0
  21. package/lib/migrate-single-user.js +2 -0
  22. package/lib/notes-lifecycle.js +149 -0
  23. package/lib/notes.js +60 -3
  24. package/lib/os-users.js +3 -1
  25. package/lib/project-capsule-catalog.js +9 -1
  26. package/lib/project-capsule-turn.js +96 -0
  27. package/lib/project-connection.js +14 -1
  28. package/lib/project-http.js +15 -1
  29. package/lib/project-knowledge.js +22 -0
  30. package/lib/project-log-context.js +172 -0
  31. package/lib/project-log-feedback-delivery.js +118 -0
  32. package/lib/project-logs-comments.js +137 -0
  33. package/lib/project-logs-context-state.js +42 -0
  34. package/lib/project-logs-mcp-server.js +328 -0
  35. package/lib/project-logs-query.js +185 -0
  36. package/lib/project-logs-root.js +75 -0
  37. package/lib/project-logs-schema.js +259 -0
  38. package/lib/project-logs-service.js +463 -0
  39. package/lib/project-logs-snapshot.js +205 -0
  40. package/lib/project-logs-store.js +495 -0
  41. package/lib/project-logs-versioning.js +132 -0
  42. package/lib/project-logs.js +400 -0
  43. package/lib/project-mate-interaction.js +14 -0
  44. package/lib/project-mate-knowledge.js +110 -0
  45. package/lib/project-memory.js +22 -0
  46. package/lib/project-message-delivery.js +70 -0
  47. package/lib/project-pair-lifecycle.js +460 -0
  48. package/lib/project-session-handoff.js +1 -1
  49. package/lib/project-session-notes.js +64 -13
  50. package/lib/project-session-pair.js +159 -146
  51. package/lib/project-session-spawn.js +1 -1
  52. package/lib/project-sessions.js +58 -13
  53. package/lib/project-user-message.js +53 -4
  54. package/lib/project-vendor-login.js +20 -3
  55. package/lib/project-worker-permission.js +430 -0
  56. package/lib/project-worker-proposal.js +208 -62
  57. package/lib/project.js +178 -6
  58. package/lib/public/app.js +58 -35
  59. package/lib/public/css/admin.css +2 -2
  60. package/lib/public/css/capsule-ui.css +38 -0
  61. package/lib/public/css/command-palette.css +19 -9
  62. package/lib/public/css/git-placard.css +154 -0
  63. package/lib/public/css/home-hub.css +4 -0
  64. package/lib/public/css/home-session-actions.css +66 -0
  65. package/lib/public/css/home-sidebar.css +66 -0
  66. package/lib/public/css/input.css +163 -28
  67. package/lib/public/css/mobile-nav.css +80 -0
  68. package/lib/public/css/notifications-center.css +67 -0
  69. package/lib/public/css/pane.css +52 -0
  70. package/lib/public/css/project-logs.css +198 -0
  71. package/lib/public/css/scheduler.css +1 -1
  72. package/lib/public/css/session-actions.css +16 -101
  73. package/lib/public/css/sidebar.css +103 -0
  74. package/lib/public/css/sticky-notes.css +237 -229
  75. package/lib/public/css/worker-proposal.css +34 -1
  76. package/lib/public/index.html +45 -11
  77. package/lib/public/modules/admin.js +8 -8
  78. package/lib/public/modules/app-connection.js +109 -4
  79. package/lib/public/modules/app-home-hub.js +19 -7
  80. package/lib/public/modules/app-message-cards.js +2 -1
  81. package/lib/public/modules/app-messages.js +84 -7
  82. package/lib/public/modules/app-misc.js +2 -14
  83. package/lib/public/modules/app-notifications.js +11 -7
  84. package/lib/public/modules/app-panels.js +46 -4
  85. package/lib/public/modules/app-projects.js +13 -8
  86. package/lib/public/modules/app-rendering.js +1 -0
  87. package/lib/public/modules/background-tasks-ui.js +139 -16
  88. package/lib/public/modules/capsule-preference.js +44 -0
  89. package/lib/public/modules/chat-bubble-renderer.js +1 -1
  90. package/lib/public/modules/clay-log-links.js +70 -0
  91. package/lib/public/modules/command-palette.js +52 -15
  92. package/lib/public/modules/context-sources.js +0 -3
  93. package/lib/public/modules/git-agent-sessions.js +85 -0
  94. package/lib/public/modules/git-panel.js +111 -84
  95. package/lib/public/modules/git-placard.js +161 -0
  96. package/lib/public/modules/home-chat-empty-state.js +39 -0
  97. package/lib/public/modules/home-chat-identity.js +9 -0
  98. package/lib/public/modules/home-conversations-sheet.js +102 -48
  99. package/lib/public/modules/home-mate-chat.js +15 -35
  100. package/lib/public/modules/home-session-actions.js +6 -0
  101. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  102. package/lib/public/modules/home-sidebar.js +7 -0
  103. package/lib/public/modules/home-tool-frame.js +182 -0
  104. package/lib/public/modules/home-tools.js +138 -1
  105. package/lib/public/modules/input.js +38 -7
  106. package/lib/public/modules/markdown.js +2 -0
  107. package/lib/public/modules/mate-sidebar.js +0 -8
  108. package/lib/public/modules/message-delivery.js +97 -0
  109. package/lib/public/modules/paste-modal.js +84 -0
  110. package/lib/public/modules/project-logs-render.js +405 -0
  111. package/lib/public/modules/project-logs.js +461 -0
  112. package/lib/public/modules/project-removal-target.js +26 -0
  113. package/lib/public/modules/scheduler.js +17 -3
  114. package/lib/public/modules/session-actions.js +29 -105
  115. package/lib/public/modules/session-hierarchy.js +54 -0
  116. package/lib/public/modules/sidebar-mobile.js +40 -15
  117. package/lib/public/modules/sidebar-session-hierarchy.js +214 -0
  118. package/lib/public/modules/sidebar-sessions.js +77 -11
  119. package/lib/public/modules/sidebar.js +17 -5
  120. package/lib/public/modules/split-group-helpers.js +8 -0
  121. package/lib/public/modules/split-view.js +5 -2
  122. package/lib/public/modules/sticky-note-markdown.js +5 -0
  123. package/lib/public/modules/sticky-notes-browser.js +326 -0
  124. package/lib/public/modules/sticky-notes-card.js +360 -0
  125. package/lib/public/modules/sticky-notes-editor.js +291 -0
  126. package/lib/public/modules/sticky-notes-shared.js +90 -0
  127. package/lib/public/modules/sticky-notes.js +170 -946
  128. package/lib/public/modules/tool-palette-order.js +143 -0
  129. package/lib/public/modules/tool-palette-overlays.js +159 -0
  130. package/lib/public/modules/tool-palette.js +21 -188
  131. package/lib/public/modules/tools.js +21 -1
  132. package/lib/public/modules/update-snooze.js +169 -0
  133. package/lib/public/modules/user-settings.js +4 -0
  134. package/lib/public/modules/worker-pane-lock.js +176 -0
  135. package/lib/public/modules/worker-proposal-state.js +16 -0
  136. package/lib/public/modules/worker-proposal.js +49 -12
  137. package/lib/public/style.css +3 -1
  138. package/lib/sdk-bridge.js +132 -46
  139. package/lib/sdk-message-processor.js +60 -14
  140. package/lib/server-experimental-settings.js +49 -0
  141. package/lib/server-global-ws.js +11 -0
  142. package/lib/server-home-chat-events.js +31 -1
  143. package/lib/server-home-chat.js +27 -4
  144. package/lib/server-home-clay-entry.js +1 -1
  145. package/lib/server-home-clay-session-links.js +34 -5
  146. package/lib/server-settings.js +5 -0
  147. package/lib/server-tools.js +127 -9
  148. package/lib/server.js +124 -18
  149. package/lib/session-driver-eligibility.js +38 -0
  150. package/lib/session-driver-orchestration.js +31 -0
  151. package/lib/session-hygiene.js +3 -0
  152. package/lib/session-notes-mcp-server.js +25 -5
  153. package/lib/session-pair-factory.js +247 -0
  154. package/lib/session-pair-mcp-server.js +46 -8
  155. package/lib/session-pair-prompts.js +79 -0
  156. package/lib/session-pair-turn-control.js +153 -0
  157. package/lib/session-provenance.js +119 -0
  158. package/lib/session-spawn-mcp-server.js +1 -1
  159. package/lib/session-split-groups.js +4 -1
  160. package/lib/session-title-policy.js +60 -0
  161. package/lib/session-visibility.js +39 -0
  162. package/lib/sessions.js +61 -11
  163. package/lib/tools-registry.js +89 -10
  164. package/lib/update-snooze.js +437 -0
  165. package/lib/users-experimental-preferences.js +30 -0
  166. package/lib/users-permissions.js +2 -2
  167. package/lib/users.js +22 -16
  168. package/lib/workspace-query-access.js +102 -0
  169. package/lib/workspace-query-service.js +24 -17
  170. package/lib/worktree.js +25 -38
  171. package/lib/ws-schema.js +38 -7
  172. package/lib/yoke/adapters/claude-worker.js +16 -6
  173. package/lib/yoke/adapters/claude.js +81 -4
  174. package/lib/yoke/adapters/codex.js +63 -15
  175. package/lib/yoke/codex-background-tasks.js +8 -2
  176. package/lib/yoke/vendor-registry.js +1 -1
  177. 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
- var updated = nm.update(msg.id, msg);
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
- if (msg.type === "note_delete") {
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
- if (nm.remove(msg.id)) {
122
- send({ type: "note_deleted", id: msg.id });
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
- var refresh = succeeded ? refreshVendorAuth(vendor) : Promise.resolve(false);
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
+ };