clay-server 4.0.0 → 4.1.0-beta.1

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 (52) hide show
  1. package/lib/daemon-project-access.js +118 -0
  2. package/lib/daemon-projects.js +19 -5
  3. package/lib/daemon.js +19 -18
  4. package/lib/project-logs-access-scope.js +94 -0
  5. package/lib/project-logs-query.js +3 -0
  6. package/lib/project-logs-service.js +86 -84
  7. package/lib/project-logs-store.js +5 -3
  8. package/lib/project-pair-lifecycle.js +71 -61
  9. package/lib/project-pair-message.js +50 -0
  10. package/lib/project-pair-replacement-state.js +72 -0
  11. package/lib/project-pair-task-control.js +264 -0
  12. package/lib/project-pair-usage.js +73 -0
  13. package/lib/project-session-pair.js +115 -116
  14. package/lib/project-sessions.js +40 -6
  15. package/lib/project-worker-proposal.js +54 -57
  16. package/lib/project.js +27 -4
  17. package/lib/public/app.js +1 -0
  18. package/lib/public/css/command-palette.css +26 -0
  19. package/lib/public/css/icon-strip.css +40 -0
  20. package/lib/public/index.html +2 -2
  21. package/lib/public/modules/app-message-router.js +1 -1
  22. package/lib/public/modules/app-messages.js +9 -0
  23. package/lib/public/modules/project-access.js +141 -0
  24. package/lib/public/modules/project-grouping.js +22 -0
  25. package/lib/public/modules/search-clay-chat.js +47 -6
  26. package/lib/public/modules/search-clay-controls.js +86 -0
  27. package/lib/public/modules/sidebar-projects.js +14 -171
  28. package/lib/public/modules/worker-proposal.js +4 -0
  29. package/lib/public/style.css +1 -1
  30. package/lib/sdk-bridge.js +21 -1
  31. package/lib/server-admin.js +3 -3
  32. package/lib/server-global-ws.js +10 -0
  33. package/lib/server-home-chat-events.js +3 -0
  34. package/lib/server-home-chat.js +35 -0
  35. package/lib/server-mates.js +4 -3
  36. package/lib/server-palette.js +17 -14
  37. package/lib/server.js +184 -102
  38. package/lib/session-pair-factory.js +3 -0
  39. package/lib/session-pair-history.js +40 -0
  40. package/lib/session-pair-mcp-server.js +12 -1
  41. package/lib/session-pair-operation.js +11 -0
  42. package/lib/session-pair-prompts.js +24 -2
  43. package/lib/session-pair-turn-control.js +8 -3
  44. package/lib/worker-proposal-control.js +69 -0
  45. package/lib/worker-proposal-decision.js +7 -0
  46. package/lib/worker-runtime-catalog.js +52 -0
  47. package/lib/workspace-query-access.js +3 -2
  48. package/lib/workspace-query-service.js +1 -1
  49. package/lib/worktree.js +8 -4
  50. package/lib/ws-schema.js +7 -0
  51. package/lib/yoke/adapters/codex.js +6 -2
  52. package/package.json +1 -1
@@ -3,10 +3,9 @@ var yoke = require("./yoke");
3
3
  var buildShape = require("./session-spawn-mcp-server").buildShape;
4
4
  var driverEligibility = require("./session-driver-eligibility");
5
5
  var driverOrchestration = require("./session-driver-orchestration");
6
- var MAX_SUMMARY_CHARS = 600;
7
- var MAX_PLAN_CHARS = 6000;
8
- var MAX_TASK_CHARS = 30000;
9
- var MAX_RATIONALE_CHARS = 1000;
6
+ var proposalControl = require("./worker-proposal-control");
7
+ var runtimeCatalog = require("./worker-runtime-catalog");
8
+ var MAX_SUMMARY_CHARS = 600, MAX_PLAN_CHARS = 6000, MAX_TASK_CHARS = 30000, MAX_RATIONALE_CHARS = 1000;
10
9
  function modelValue(entry) {
11
10
  if (typeof entry === "string") return entry;
12
11
  return entry && (entry.value || entry.id) || "";
@@ -15,16 +14,14 @@ function modelLabel(entry) {
15
14
  if (typeof entry === "string") return entry;
16
15
  return entry && (entry.displayName || entry.name || entry.value || entry.id) || "";
17
16
  }
18
- function toolResult(value) {
19
- return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
20
- }
17
+ function toolResult(value) { return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] }); }
21
18
  function attachWorkerProposal(ctx) {
22
19
  var sm = ctx.sm;
23
20
  var store = ctx.splitStore;
21
+ var catalogErrors = {};
24
22
  function isLiveSession(session) {
25
23
  return !!session && sm.sessions.get(session.localId) === session;
26
24
  }
27
-
28
25
  function canOffer(session) { return !ctx.isMate && isLiveSession(session) && driverEligibility.isEligibleDriverSession(session, sm); }
29
26
  function isEligible(session) {
30
27
  return canOffer(session) && !store.groupForMember(session.localId);
@@ -45,7 +42,6 @@ function attachWorkerProposal(ctx) {
45
42
  }
46
43
  return result;
47
44
  }
48
-
49
45
  function proposalOptions() {
50
46
  var installed = (sm.installedVendors || []).slice();
51
47
  var capabilities = {};
@@ -59,9 +55,10 @@ function attachWorkerProposal(ctx) {
59
55
  installedVendors: installed,
60
56
  modelsByVendor: safeModelsByVendor(installed),
61
57
  capabilitiesByVendor: capabilities,
58
+ unavailableReasons: Object.assign({}, catalogErrors),
59
+ availableVendors: Array.isArray(sm.availableVendors) ? sm.availableVendors.slice() : null,
62
60
  };
63
61
  }
64
-
65
62
  async function ensureModelCatalogs() {
66
63
  var installed = sm.installedVendors || [];
67
64
  sm.modelsByVendor = sm.modelsByVendor || {};
@@ -70,11 +67,10 @@ function attachWorkerProposal(ctx) {
70
67
  if (sm.modelsByVendor[vendor] && sm.modelsByVendor[vendor].length > 0) continue;
71
68
  var adapter = ctx.adapters && ctx.adapters[vendor];
72
69
  if (!adapter || typeof adapter.supportedModels !== "function") continue;
73
- try { sm.modelsByVendor[vendor] = await adapter.supportedModels(); }
74
- catch (err) { console.warn("[worker-proposal] Could not load " + vendor + " models:", err.message || err); }
70
+ try { sm.modelsByVendor[vendor] = await adapter.supportedModels(); delete catalogErrors[vendor]; }
71
+ catch (err) { catalogErrors[vendor] = err.message || String(err); console.warn("[worker-proposal] Could not load " + vendor + " models:", catalogErrors[vendor]); }
75
72
  }
76
73
  }
77
-
78
74
  function modelIsAvailable(options, vendor, model) {
79
75
  if (!model) return true;
80
76
  var models = options.modelsByVendor[vendor] || [];
@@ -83,7 +79,6 @@ function attachWorkerProposal(ctx) {
83
79
  }
84
80
  return false;
85
81
  }
86
-
87
82
  function effortIsAvailable(options, vendor, model, effort) {
88
83
  var capabilities = options.capabilitiesByVendor[vendor] || {};
89
84
  if (capabilities.effort === false) return !effort;
@@ -97,7 +92,6 @@ function attachWorkerProposal(ctx) {
97
92
  }
98
93
  return true;
99
94
  }
100
-
101
95
  function chooseRecommendation(args, session, options) {
102
96
  var installed = options.installedVendors;
103
97
  var vendor = args.recommendedVendor;
@@ -125,12 +119,10 @@ function attachWorkerProposal(ctx) {
125
119
  var effort = yoke.clampEffort(vendor, args.recommendedEffort || "medium") || "";
126
120
  return { vendor: vendor, model: model, effort: effort };
127
121
  }
128
-
129
122
  function skipPermissionsEnabled(session) {
130
123
  return !!(session && (session.permissionMode === "bypassPermissions" ||
131
124
  session.dangerouslySkipPermissions || ctx.dangerouslySkipPermissions));
132
125
  }
133
-
134
126
  function recommendationCanAutoAccept(args, recommendation, options) {
135
127
  var requestedVendor = typeof args.recommendedVendor === "string" ? args.recommendedVendor.trim() : "";
136
128
  var requestedModel = typeof args.recommendedModel === "string" ? args.recommendedModel.trim() : "";
@@ -143,32 +135,12 @@ function attachWorkerProposal(ctx) {
143
135
  return requestedEffort === recommendation.effort &&
144
136
  effortIsAvailable(options, requestedVendor, requestedModel, requestedEffort);
145
137
  }
146
-
147
138
  function autoAcceptanceWs(session) {
148
139
  return {
149
140
  _clayActiveSession: session.localId,
150
141
  _clayUser: session.ownerId ? { id: session.ownerId } : null,
151
142
  };
152
143
  }
153
-
154
- function findProposal(session, proposalId) {
155
- var history = (session && session.history) || [];
156
- for (var i = history.length - 1; i >= 0; i--) {
157
- if (history[i] && history[i].type === "worker_proposal" && history[i].proposalId === proposalId) return history[i];
158
- }
159
- return null;
160
- }
161
-
162
- function hasPendingProposal(session) {
163
- var history = (session && session.history) || [];
164
- for (var i = history.length - 1; i >= 0; i--) {
165
- var item = history[i];
166
- if (!item || item.type !== "worker_proposal") continue;
167
- return item.status === "pending" || item.status === "starting" || item.status === "running";
168
- }
169
- return false;
170
- }
171
-
172
144
  function updateProposal(session, proposal, patch) {
173
145
  Object.assign(proposal, patch, { updatedAt: Date.now() });
174
146
  sm.saveSessionFile(session);
@@ -177,10 +149,13 @@ function attachWorkerProposal(ctx) {
177
149
  proposalId: proposal.proposalId,
178
150
  }, patch));
179
151
  }
180
-
181
152
  async function propose(args, session) {
182
153
  if (!isEligible(session)) return toolResult({ error: "Split Worker proposals are only available in an eligible unpaired Driver session." });
183
- if (hasPendingProposal(session)) return toolResult({ error: "A Split Worker suggestion is already awaiting a decision." });
154
+ var superseded = proposalControl.pendingProposal(session);
155
+ var supersedeId = typeof args.supersedeProposalId === "string" ? args.supersedeProposalId.trim() : "";
156
+ if (superseded && superseded.proposalId !== supersedeId) return toolResult({ error: "A Split Worker suggestion is already awaiting a decision. Inspect it or supply its exact id to supersede it." });
157
+ if (!superseded && supersedeId) return toolResult({ error: "The Split Worker suggestion to supersede is no longer pending." });
158
+ if (superseded && superseded.status !== "pending") return toolResult({ error: "The Split Worker suggestion is already starting and cannot be superseded." });
184
159
  var summary = typeof args.summary === "string" ? args.summary.trim() : "";
185
160
  var plan = typeof args.plan === "string" ? args.plan.trim() : "";
186
161
  var task = typeof args.message === "string" ? args.message.trim() : "";
@@ -193,6 +168,8 @@ function attachWorkerProposal(ctx) {
193
168
  if (!isEligible(session)) {
194
169
  return toolResult({ error: "The Driver session changed while Split Worker runtimes were loading." });
195
170
  }
171
+ var currentPending = proposalControl.pendingProposal(session);
172
+ if (currentPending !== superseded) return toolResult({ error: "The pending Split Worker suggestion changed while runtimes were loading." });
196
173
  var options = proposalOptions();
197
174
  if (options.installedVendors.length === 0) return toolResult({ error: "No coding agent is installed for a Split Worker session." });
198
175
  var recommendation = chooseRecommendation(args, session, options);
@@ -203,12 +180,14 @@ function attachWorkerProposal(ctx) {
203
180
  plan: plan.slice(0, MAX_PLAN_CHARS),
204
181
  message: task,
205
182
  status: "pending",
183
+ createdAt: Date.now(),
206
184
  recommendedVendor: recommendation.vendor,
207
185
  recommendedModel: recommendation.model,
208
186
  recommendedEffort: recommendation.effort,
209
187
  recommendationRationale: rationale.slice(0, MAX_RATIONALE_CHARS),
210
188
  options: options,
211
189
  };
190
+ if (superseded) updateProposal(session, superseded, { status: "superseded", supersededBy: proposal.proposalId });
212
191
  sm.sendAndRecord(session, proposal);
213
192
  if (skipPermissionsEnabled(session) && recommendationCanAutoAccept(args, recommendation, options)) {
214
193
  var accepted = await acceptProposal(session, proposal, {
@@ -230,7 +209,6 @@ function attachWorkerProposal(ctx) {
230
209
  instruction: "The Split Worker suggestion is visible in the chat. End this turn now and wait for the user's decision.",
231
210
  });
232
211
  }
233
-
234
212
  async function proposeReplacement(args, session) {
235
213
  var group = session && store.groupForMember(session.localId);
236
214
  if (ctx.isMate || !isLiveSession(session) || !driverEligibility.isEligibleDriverSession(session, sm) || !group ||
@@ -239,7 +217,11 @@ function attachWorkerProposal(ctx) {
239
217
  }
240
218
  var sourceGroupId = group.id;
241
219
  var sourceWorkerId = group.pair.workerId;
242
- if (hasPendingProposal(session)) return toolResult({ error: "A Split Worker proposal is already awaiting a decision." });
220
+ var superseded = proposalControl.pendingProposal(session);
221
+ var supersedeId = typeof args.supersedeProposalId === "string" ? args.supersedeProposalId.trim() : "";
222
+ if (superseded && superseded.proposalId !== supersedeId) return toolResult({ error: "A Split Worker proposal is already awaiting a decision. Inspect it or supply its exact id to supersede it." });
223
+ if (!superseded && supersedeId) return toolResult({ error: "The Split Worker proposal to supersede is no longer pending." });
224
+ if (superseded && superseded.status !== "pending") return toolResult({ error: "The Split Worker proposal is already starting and cannot be superseded." });
243
225
  var task = typeof args.message === "string" ? args.message.trim() : "";
244
226
  var rationale = typeof args.recommendationRationale === "string" ? args.recommendationRationale.trim() : "";
245
227
  if (!task) return toolResult({ error: "message is required so an accepted replacement delegates exactly once." });
@@ -252,6 +234,8 @@ function attachWorkerProposal(ctx) {
252
234
  liveGroup.pair.driverId !== session.localId || liveGroup.pair.workerId !== sourceWorkerId) {
253
235
  return toolResult({ error: "The Driver/Split Worker pair changed while replacement runtimes were loading." });
254
236
  }
237
+ var currentPending = proposalControl.pendingProposal(session);
238
+ if (currentPending !== superseded) return toolResult({ error: "The pending Split Worker proposal changed while runtimes were loading." });
255
239
  var options = proposalOptions();
256
240
  if (options.installedVendors.length === 0) return toolResult({ error: "No coding agent is installed for a Split Worker session." });
257
241
  var recommendationArgs = {
@@ -268,6 +252,7 @@ function attachWorkerProposal(ctx) {
268
252
  plan: "1. Preserve the current Worker's session and history\n2. Create the selected replacement runtime\n3. Delegate the next task exactly once",
269
253
  message: task,
270
254
  status: "pending",
255
+ createdAt: Date.now(),
271
256
  recommendedVendor: recommendation.vendor,
272
257
  recommendedModel: recommendation.model,
273
258
  recommendedEffort: recommendation.effort,
@@ -277,7 +262,9 @@ function attachWorkerProposal(ctx) {
277
262
  sourceWorkerId: sourceWorkerId,
278
263
  interrupt: args.interrupt === true,
279
264
  evaluation: args.evaluation || null,
265
+ transactionId: "replace_" + crypto.randomUUID(),
280
266
  };
267
+ if (superseded) updateProposal(session, superseded, { status: "superseded", supersededBy: proposal.proposalId });
281
268
  sm.sendAndRecord(session, proposal);
282
269
  if (skipPermissionsEnabled(session) && recommendationCanAutoAccept(recommendationArgs, recommendation, options)) {
283
270
  var accepted = await acceptProposal(session, proposal, {
@@ -299,7 +286,6 @@ function attachWorkerProposal(ctx) {
299
286
  instruction: "The replacement configuration is visible in the chat. End this turn now and wait for the user's decision.",
300
287
  });
301
288
  }
302
-
303
289
  function resumeDriver(session, text) {
304
290
  var sdk = ctx.getSdk();
305
291
  if (!sdk) return Promise.reject(new Error("SDK bridge is not ready"));
@@ -335,7 +321,7 @@ function attachWorkerProposal(ctx) {
335
321
  if (result.status === "complete") {
336
322
  followup = "[Split Worker execution completed]\nReview and verify the Split Worker's result. The Split Worker session remains available: if the implementation needs corrections or additional edits, send a follow-up with send_to_partner instead of taking over the Split Worker-owned files yourself. If that Split Worker is no longer available, keep the work in the visible Split Worker flow and use send_to_partner again after the stale pair is removed; never substitute a background Sub-agent.\n\n" + (result.response || "The Split Worker completed without a text summary.");
337
323
  } else if (result.status === "interrupted") {
338
- followup = "[Split Worker execution interrupted]\nThe user interrupted the Split Worker mid-turn. Its work is PARTIAL and unverified — do not treat it as finished. Review what was done and decide next steps with the user.";
324
+ followup = "[Split Worker execution interrupted]\nThe Split Worker stopped mid-turn. Its work is PARTIAL and unverified — do not treat it as finished. Check partner_status before deciding next steps; this result alone does not identify who interrupted it. If the human stopped it, do not retry until a new human Driver message. Otherwise continue within the user's authorized task.";
339
325
  } else if (result.status === "running") {
340
326
  followup = "[Split Worker execution is still running]\nUse read_partner to inspect progress before completing the task.";
341
327
  } else {
@@ -398,6 +384,7 @@ function attachWorkerProposal(ctx) {
398
384
  workerModel: model,
399
385
  workerEffort: effort,
400
386
  evaluation: proposal.evaluation || undefined,
387
+ transactionId: proposal.transactionId,
401
388
  }, session);
402
389
  liveGroup = store.groupForMember(session.localId);
403
390
  updateProposal(session, proposal, { status: "running", groupId: liveGroup.id, workerId: replaced.workerSessionId });
@@ -426,6 +413,7 @@ function attachWorkerProposal(ctx) {
426
413
  restoredGroup.pair.workerId === proposal.sourceWorkerId) {
427
414
  proposal.sourceGroupId = restoredGroup.id;
428
415
  }
416
+ proposal.transactionId = "replace_" + crypto.randomUUID();
429
417
  }
430
418
  updateProposal(session, proposal, {
431
419
  status: "pending",
@@ -440,7 +428,7 @@ function attachWorkerProposal(ctx) {
440
428
 
441
429
  async function respondToProposal(ws, msg) {
442
430
  var session = sessionForResponse(ws);
443
- var proposal = findProposal(session, msg.proposalId);
431
+ var proposal = proposalControl.findProposal(session, msg.proposalId);
444
432
  if (!proposal) throw new Error("Split Worker suggestion not found");
445
433
  if (proposal.status !== "pending") throw new Error("Split Worker suggestion has already been resolved");
446
434
  if (!msg.accepted) {
@@ -464,8 +452,9 @@ function attachWorkerProposal(ctx) {
464
452
 
465
453
  function getToolDefs(session, options) {
466
454
  var persistent = !!(options && options.persistent);
455
+ var controlsOnly = !!(options && options.controlsOnly);
467
456
  if (!isEligible(session) && !(persistent && canOffer(session))) return [];
468
- return [{
457
+ var proposalTools = controlsOnly ? [] : [{
469
458
  name: "propose_worker",
470
459
  description: "Propose a visible Split Worker for implementation-heavy execution. This non-mutating tool only shows the user a card where they choose vendor, model, and effort. It never creates a session or delegates work until the user accepts.",
471
460
  inputSchema: buildShape({
@@ -476,25 +465,33 @@ function attachWorkerProposal(ctx) {
476
465
  recommendedModel: { type: "string", description: "Optional exact Split Worker model id. Omit when uncertain." },
477
466
  recommendedEffort: { type: "string", description: "Optional reasoning effort: minimal, low, medium, high, xhigh, or max." },
478
467
  recommendationRationale: { type: "string", description: "Concise Driver-authored explanation of why the recommended vendor, model, and effort fit this exact task." },
468
+ supersedeProposalId: { type: "string", description: "Exact pending proposal id to resolve as superseded when posting this replacement suggestion." },
479
469
  }, ["summary", "plan", "message", "recommendationRationale"]),
480
470
  handler: function (args) { return propose(args || {}, session); },
481
471
  }];
472
+ return proposalTools.concat(proposalControl.getToolDefs({
473
+ inspect: function () { return toolResult(isLiveSession(session) ? proposalControl.projection(proposalControl.pendingProposal(session)) : { status: "rejected", reason: "The Driver session is no longer live." }); },
474
+ cancel: function (args) {
475
+ if (!isLiveSession(session)) return toolResult({ status: "rejected", proposalId: args.proposalId || null, reason: "The Driver session is no longer live." });
476
+ var proposal = proposalControl.findProposal(session, args.proposalId);
477
+ if (!proposal || proposal.status !== "pending") return toolResult({ status: "rejected", proposalId: args.proposalId || null, reason: "The exact proposal is not pending." });
478
+ updateProposal(session, proposal, { status: "cancelled" });
479
+ return toolResult({ status: "cancelled", proposalId: proposal.proposalId });
480
+ },
481
+ catalog: function () { return ensureModelCatalogs().then(function () {
482
+ return toolResult(runtimeCatalog.build(proposalOptions(), skipPermissionsEnabled(session), function (vendor, model, effort) {
483
+ return ctx.preflightWorkerForDriver ? ctx.preflightWorkerForDriver(session, { workerVendor: vendor, workerModel: model, workerEffort: effort || "" }) : null;
484
+ }));
485
+ }); },
486
+ }));
482
487
  }
483
488
 
484
489
  function getSystemPrompt(session) {
485
490
  if (!isEligible(session) || !driverOrchestration.isHighTierDriverSession(session, sm)) return "";
486
491
  return "For implementation-heavy work, use propose_worker before substantial execution. Work is implementation-heavy when it spans multiple modules, crosses client/server/data boundaries, requires a migration, or contains independent investigation and execution. Do not skip delegation merely because you can implement it yourself; preserve the Driver for decomposition, runtime selection, judgment, review, and integration. The user's explicit request to work directly takes precedence, and work that must remain sequential in this session stays here. Recommend an exact available vendor, model, and effort, and give a concise recommendationRationale explaining why all three fit the task. Clay always records and shows the runtime configuration card. In full-access mode Clay may auto-accept that exact validated recommendation; otherwise it waits for the user's explicit choice. After posting, end the turn. If accepted, Clay creates the exact Driver/Split Worker pair and delegates the proposed task once. If declined, continue in this Driver session. Do not call send_to_partner while unpaired.";
487
492
  }
488
-
489
- return {
490
- getToolDefs: getToolDefs,
491
- getSystemPrompt: getSystemPrompt,
492
- proposeReplacement: proposeReplacement,
493
- handleMessage: handleMessage,
494
- respondToProposal: respondToProposal,
495
- };
493
+ return { getToolDefs: getToolDefs, getSystemPrompt: getSystemPrompt, proposeReplacement: proposeReplacement,
494
+ handleMessage: handleMessage, respondToProposal: respondToProposal,
495
+ proposalStatus: function (session) { return proposalControl.projection(proposalControl.pendingProposal(session)); } };
496
496
  }
497
-
498
- module.exports = {
499
- attachWorkerProposal: attachWorkerProposal,
500
- };
497
+ module.exports = { attachWorkerProposal: attachWorkerProposal };
package/lib/project.js CHANGED
@@ -390,14 +390,31 @@ function createProjectContext(opts) {
390
390
  }
391
391
 
392
392
  function send(obj) {
393
- var data = JSON.stringify(obj);
394
393
  for (var ws of clients) {
395
- if (ws.readyState === 1) ws.send(data);
394
+ if (ws.readyState !== 1) continue;
395
+ var payload = obj;
396
+ if (obj && obj.type === "info") {
397
+ var infoUserId = ws._clayUser ? ws._clayUser.id : null;
398
+ var visibleProjects = getProjectList(infoUserId);
399
+ payload = Object.assign({}, obj, { projects: visibleProjects, projectCount: visibleProjects.length });
400
+ } else if (obj && obj.type === "loop_registry_updated") {
401
+ var userId = ws._clayUser ? ws._clayUser.id : null;
402
+ payload = Object.assign({}, obj, { records: getHubSchedules(userId) });
403
+ }
404
+ ws.send(JSON.stringify(payload));
396
405
  }
397
406
  }
398
407
 
399
408
  function sendTo(ws, obj) {
400
- if (ws.readyState === 1) ws.send(JSON.stringify(obj));
409
+ if (ws.readyState !== 1) return;
410
+ var payload = obj;
411
+ if (obj && (obj.type === "loop_registry_updated" || obj.type === "hub_schedules")) {
412
+ var userId = ws._clayUser ? ws._clayUser.id : null;
413
+ payload = Object.assign({}, obj);
414
+ if (obj.type === "loop_registry_updated") payload.records = getHubSchedules(userId);
415
+ else payload.schedules = getHubSchedules(userId);
416
+ }
417
+ ws.send(JSON.stringify(payload));
401
418
  }
402
419
 
403
420
  function sendToAdmins(obj) {
@@ -1331,6 +1348,10 @@ function createProjectContext(opts) {
1331
1348
  if (typeof opts.onDmMessage === "function") opts.onDmMessage(ws, msg, slug);
1332
1349
  return;
1333
1350
  }
1351
+ if (msg.type === "home_mate_stop" || msg.type === "home_mate_permission_response") {
1352
+ if (typeof opts.onDmMessage === "function") opts.onDmMessage(ws, msg, slug);
1353
+ return;
1354
+ }
1334
1355
  if (msg.type === "project_assignment_response" || msg.type === "dm_open" || msg.type === "dm_send" || msg.type === "dm_list" || msg.type === "dm_typing" || msg.type === "dm_add_favorite" || msg.type === "dm_remove_favorite" || msg.type === "mate_create" || msg.type === "mate_list" || msg.type === "mate_delete" || msg.type === "mate_update" || msg.type === "mate_readd_builtin" || msg.type === "mate_list_available_builtins" || msg.type === "email_accounts_list" || msg.type === "email_account_add" || msg.type === "email_account_remove" || msg.type === "email_account_test" || msg.type === "home_debates_list" || msg.type === "home_mate_present" || msg.type === "home_mate_open" || msg.type === "home_mate_sessions_list" || msg.type === "home_mate_session_open" || msg.type === "home_mate_send" || msg.type === "home_mate_new_session" || msg.type === "home_mate_debate_plan" || msg.type === "home_debate_proposal_response" || msg.type === "home_mate_creation_plan" || msg.type === "home_mate_creation_proposal_response" || msg.type === "home_mate_close" || msg.type === "home_mate_memory_list" || msg.type === "home_mate_knowledge_list" || msg.type === "home_mate_models_get" || msg.type === "home_mate_model_set" || msg.type === "home_dock_get" || msg.type === "home_dock_set" || msg.type === "home_surface_get" || msg.type === "home_surface_set" || msg.type === "tools_list" || msg.type === "tool_get" || msg.type === "tool_install" || msg.type === "tool_remove" || msg.type === "tool_storage_op" || msg.type === "tool_llm_op" || msg.type === "tool_llm_config_get" || msg.type === "tool_control_response" || msg.type === "tool_source_get" || msg.type === "tool_mate_access_set" || msg.type === "tool_server_control" || msg.type === "tool_frame_url") {
1335
1356
  if (typeof opts.onDmMessage === "function") {
1336
1357
  opts.onDmMessage(ws, msg, slug);
@@ -2252,7 +2273,7 @@ function createProjectContext(opts) {
2252
2273
  var _mateId = path.basename(cwd);
2253
2274
  var _mateCtx = matesModule.buildMateCtx(projectOwnerId);
2254
2275
  // Collect non-mate projects for project registry injection
2255
- var _projectList = (getProjectList() || []).filter(function (p) { return !p.isMate; });
2276
+ var _projectList = (getProjectList(projectOwnerId) || []).filter(function (p) { return !p.isMate; });
2256
2277
  var _enforceOpts = { ctx: _mateCtx, mateId: _mateId, projects: _projectList };
2257
2278
  // Enforce all system sections atomically on startup (single read/write)
2258
2279
  var _selfWrite = false; // suppress watcher when we wrote the file ourselves
@@ -2314,6 +2335,8 @@ function createProjectContext(opts) {
2314
2335
  handleHomeDebateProposalResponse: function (ws, msg, session) { return _debateProposal.handleHomeMessage(ws, msg, session); },
2315
2336
  handleHomeMateCreationProposalResponse: function (ws, msg, session) { return _mateCreationProposal.handleHomeMessage(ws, msg, session); },
2316
2337
  handleHomeAskUserResponse: function (msg, session) { return _sessions.respondToAskUser(session, msg); },
2338
+ handleHomePermissionResponse: function (ws, msg, session) { return _sessions.respondToHomePermission(ws, msg, session); },
2339
+ stopHomeSession: function (session) { return _sessions.stopHomeSession(session); },
2317
2340
  handleHomeDebateControl: function (ws, msg, session) { return _debate.handleHomeControl(ws, msg, session); },
2318
2341
  getVendorModelCatalog: _models.getVendorCatalog,
2319
2342
  getVendorModelAvailability: _models.getVendorAvailability,
package/lib/public/app.js CHANGED
@@ -294,6 +294,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
294
294
  savedMainSlug: null,
295
295
  connected: false,
296
296
  pendingOutboundMessages: [],
297
+ searchClayChatState: null,
297
298
  homeChatMateId: null,
298
299
  homeChatSessionId: null,
299
300
  homeChatSessionModel: null,
@@ -392,6 +392,32 @@
392
392
  gap: 12px;
393
393
  }
394
394
 
395
+ .search-clay-permission {
396
+ margin: 0 6px;
397
+ padding: 12px;
398
+ border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border-subtle));
399
+ border-radius: 10px;
400
+ background: color-mix(in srgb, var(--accent) 6%, var(--bg-secondary));
401
+ }
402
+
403
+ .search-clay-permission strong { display: block; color: var(--text); font-size: 13px; }
404
+ .search-clay-permission p { margin: 5px 0 10px; color: var(--text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
405
+ .search-clay-permission > div { display: flex; justify-content: flex-end; gap: 7px; }
406
+ .search-clay-permission button {
407
+ min-height: 30px;
408
+ padding: 0 10px;
409
+ border: 1px solid var(--border);
410
+ border-radius: 7px;
411
+ background: var(--bg);
412
+ color: var(--text);
413
+ font: inherit;
414
+ font-size: 11px;
415
+ cursor: pointer;
416
+ }
417
+ .search-clay-permission button.is-primary { border-color: var(--accent); background: var(--accent); color: white; }
418
+ .search-clay-permission button:disabled { opacity: 0.55; cursor: default; }
419
+ .search-clay-permission small { display: block; margin-top: 7px; color: var(--text-muted); font-size: 11px; }
420
+
395
421
  .search-clay-message { width: 100%; min-width: 0; }
396
422
  .search-clay-transcript .search-clay-message-user {
397
423
  display: flex;
@@ -593,6 +593,37 @@
593
593
  line-height: 1;
594
594
  }
595
595
  .project-access-close:hover { color: var(--text); }
596
+ .project-access-scope {
597
+ display: flex;
598
+ gap: 10px;
599
+ margin: 12px 14px 4px;
600
+ padding: 10px;
601
+ color: var(--text);
602
+ background: rgba(var(--overlay-rgb), 0.045);
603
+ border: 1px solid var(--border);
604
+ border-radius: 9px;
605
+ }
606
+ .project-access-scope-icon {
607
+ display: flex;
608
+ align-items: center;
609
+ justify-content: center;
610
+ width: 28px;
611
+ height: 28px;
612
+ flex: 0 0 28px;
613
+ color: var(--accent, #6366f1);
614
+ background: var(--accent-8, rgba(99, 102, 241, 0.08));
615
+ border-radius: 7px;
616
+ }
617
+ .project-access-scope-icon .lucide { width: 15px; height: 15px; }
618
+ .project-access-scope strong,
619
+ .project-access-scope small { display: block; }
620
+ .project-access-scope strong { font-size: 12px; font-weight: 600; }
621
+ .project-access-scope small {
622
+ margin-top: 2px;
623
+ color: var(--text-secondary);
624
+ font-size: 10px;
625
+ line-height: 1.35;
626
+ }
596
627
  .project-access-section {
597
628
  padding: 10px 14px;
598
629
  }
@@ -653,6 +684,15 @@
653
684
  cursor: pointer;
654
685
  }
655
686
  .project-access-user-item:hover { color: var(--text); }
687
+ .project-access-user-item small {
688
+ margin-left: auto;
689
+ color: var(--text-tertiary);
690
+ font-size: 9px;
691
+ text-transform: uppercase;
692
+ letter-spacing: 0.04em;
693
+ }
694
+ .project-access-user-item.is-inherited { color: var(--text-secondary); cursor: default; }
695
+ .project-access-user-item.is-inherited input { cursor: default; }
656
696
  .project-access-empty {
657
697
  font-size: 12px;
658
698
  color: var(--text-tertiary);
@@ -31,7 +31,7 @@
31
31
  (function(){try{var k="clay-theme-vars",v=localStorage.getItem(k),r=document.documentElement;if(v){var o=JSON.parse(v),p;for(p in o)r.style.setProperty(p,o[p]);var vt=localStorage.getItem(k.replace("-vars","-variant"));if(vt==="light"){r.classList.add("light-theme");r.classList.remove("dark-theme")}else{r.classList.add("dark-theme");r.classList.remove("light-theme")}var m=document.querySelector('meta[name="theme-color"]');if(m&&o["--bg"])m.setAttribute("content",o["--bg"])}else{var sl=window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches;if(sl){r.classList.add("light-theme");r.classList.remove("dark-theme")}}}catch(e){}})();
32
32
  </script>
33
33
  <script>if(window.navigator.standalone||window.matchMedia("(display-mode:standalone)").matches){document.documentElement.classList.add("pwa-standalone")}</script>
34
- <link rel="stylesheet" href="style.css?v=20260908-log-links2">
34
+ <link rel="stylesheet" href="style.css?v=20260910-ask-clay-controls1">
35
35
  <style>
36
36
  @media(max-width:768px){
37
37
  /* User messages: vertical stack, avatar on top, right-aligned */
@@ -2337,7 +2337,7 @@
2337
2337
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0/lib/addon-fit.min.js"></script>
2338
2338
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0/lib/addon-web-links.min.js"></script>
2339
2339
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0/lib/addon-webgl.min.js"></script>
2340
- <script type="module" src="app.js?v=20260908-log-links2"></script>
2340
+ <script type="module" src="app.js?v=20260910-ask-clay-controls1"></script>
2341
2341
  <div id="pwa-install-modal" class="pwa-modal hidden">
2342
2342
  <div class="pwa-modal-backdrop"></div>
2343
2343
  <div class="pwa-modal-card">
@@ -78,7 +78,7 @@ export function handleHomeProtocolMessage(msg) {
78
78
 
79
79
  export function processMessage(msg) {
80
80
  var searchHandled = handleSearchClayMessage(msg);
81
- if (searchHandled && msg.type === "home_clay_activity") return;
81
+ if (searchHandled && (msg.type === "home_clay_activity" || msg.type === "home_mate_stopping" || msg.type.indexOf("home_mate_permission_") === 0)) return;
82
82
  if (handleHomeProtocolMessage(msg)) return;
83
83
  processAppMessage(msg);
84
84
  }
@@ -89,6 +89,10 @@ var inputEl = document.getElementById("input");
89
89
  var connectOverlay = document.getElementById("connect-overlay");
90
90
 
91
91
  export function processMessage(msg) {
92
+ if (msg.type === "access_revoked") {
93
+ window.location.assign("/");
94
+ return;
95
+ }
92
96
  // Preserve original timestamp from history replay
93
97
  store.set({ currentMsgTs: msg._ts || null });
94
98
  var isMateDm = store.get('dmMode') && store.get('dmTargetUser') && store.get('dmTargetUser').isMate;
@@ -723,6 +727,11 @@ export function processMessage(msg) {
723
727
  syncPaneTitles();
724
728
  break;
725
729
 
730
+ case "partner_task_completed":
731
+ // The result is resumed into the Driver transcript; this correlated
732
+ // event is available to clients that track task state separately.
733
+ break;
734
+
726
735
  case "session_presence":
727
736
  updateSessionPresence(msg.presence || {});
728
737
  break;