clay-server 4.0.1-beta.1 → 4.1.0-beta.2

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 (46) 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 +140 -102
  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 +76 -0
  13. package/lib/project-session-pair.js +115 -116
  14. package/lib/project-sessions.js +25 -1
  15. package/lib/project-worker-proposal.js +69 -58
  16. package/lib/project.js +21 -4
  17. package/lib/public/css/icon-strip.css +40 -0
  18. package/lib/public/modules/app-messages.js +9 -0
  19. package/lib/public/modules/project-access.js +141 -0
  20. package/lib/public/modules/project-grouping.js +22 -0
  21. package/lib/public/modules/sidebar-projects.js +14 -171
  22. package/lib/public/modules/worker-proposal.js +4 -0
  23. package/lib/sdk-bridge.js +20 -0
  24. package/lib/server-admin.js +3 -3
  25. package/lib/server-global-ws.js +10 -0
  26. package/lib/server-mates.js +4 -3
  27. package/lib/server-palette.js +17 -14
  28. package/lib/server.js +184 -102
  29. package/lib/session-pair-factory.js +3 -0
  30. package/lib/session-pair-history.js +40 -0
  31. package/lib/session-pair-mcp-server.js +19 -2
  32. package/lib/session-pair-operation.js +11 -0
  33. package/lib/session-pair-prompts.js +24 -2
  34. package/lib/session-pair-turn-control.js +8 -3
  35. package/lib/session-provenance.js +1 -0
  36. package/lib/session-spawn-mcp-server.js +2 -1
  37. package/lib/sessions.js +2 -0
  38. package/lib/worker-proposal-control.js +69 -0
  39. package/lib/worker-proposal-decision.js +7 -0
  40. package/lib/worker-runtime-catalog.js +52 -0
  41. package/lib/workspace-query-access.js +3 -2
  42. package/lib/workspace-query-service.js +1 -1
  43. package/lib/worktree.js +8 -4
  44. package/lib/ws-schema.js +1 -0
  45. package/lib/yoke/adapters/codex.js +25 -7
  46. package/package.json +1 -1
@@ -3,10 +3,21 @@ 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;
9
+ var EVALUATION_OUTCOMES = ["succeeded", "partial", "failed", "abandoned"];
10
+ function normalizeEvaluation(raw) {
11
+ if (raw == null) return null;
12
+ var input = typeof raw === "string" ? { outcome: raw } : raw;
13
+ if (!input || typeof input !== "object") return { error: "evaluation must be an outcome string or object" };
14
+ var outcome = typeof input.outcome === "string" ? input.outcome.trim().toLowerCase() : "";
15
+ if (EVALUATION_OUTCOMES.indexOf(outcome) === -1) {
16
+ return { error: "evaluation outcome must be one of: " + EVALUATION_OUTCOMES.join(", ") };
17
+ }
18
+ var note = typeof input.note === "string" ? input.note.replace(/\s+/g, " ").trim().slice(0, 400) : "";
19
+ return { value: { outcome: outcome, note: note } };
20
+ }
10
21
  function modelValue(entry) {
11
22
  if (typeof entry === "string") return entry;
12
23
  return entry && (entry.value || entry.id) || "";
@@ -15,16 +26,14 @@ function modelLabel(entry) {
15
26
  if (typeof entry === "string") return entry;
16
27
  return entry && (entry.displayName || entry.name || entry.value || entry.id) || "";
17
28
  }
18
- function toolResult(value) {
19
- return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
20
- }
29
+ function toolResult(value) { return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] }); }
21
30
  function attachWorkerProposal(ctx) {
22
31
  var sm = ctx.sm;
23
32
  var store = ctx.splitStore;
33
+ var catalogErrors = {};
24
34
  function isLiveSession(session) {
25
35
  return !!session && sm.sessions.get(session.localId) === session;
26
36
  }
27
-
28
37
  function canOffer(session) { return !ctx.isMate && isLiveSession(session) && driverEligibility.isEligibleDriverSession(session, sm); }
29
38
  function isEligible(session) {
30
39
  return canOffer(session) && !store.groupForMember(session.localId);
@@ -45,7 +54,6 @@ function attachWorkerProposal(ctx) {
45
54
  }
46
55
  return result;
47
56
  }
48
-
49
57
  function proposalOptions() {
50
58
  var installed = (sm.installedVendors || []).slice();
51
59
  var capabilities = {};
@@ -59,9 +67,10 @@ function attachWorkerProposal(ctx) {
59
67
  installedVendors: installed,
60
68
  modelsByVendor: safeModelsByVendor(installed),
61
69
  capabilitiesByVendor: capabilities,
70
+ unavailableReasons: Object.assign({}, catalogErrors),
71
+ availableVendors: Array.isArray(sm.availableVendors) ? sm.availableVendors.slice() : null,
62
72
  };
63
73
  }
64
-
65
74
  async function ensureModelCatalogs() {
66
75
  var installed = sm.installedVendors || [];
67
76
  sm.modelsByVendor = sm.modelsByVendor || {};
@@ -70,11 +79,10 @@ function attachWorkerProposal(ctx) {
70
79
  if (sm.modelsByVendor[vendor] && sm.modelsByVendor[vendor].length > 0) continue;
71
80
  var adapter = ctx.adapters && ctx.adapters[vendor];
72
81
  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); }
82
+ try { sm.modelsByVendor[vendor] = await adapter.supportedModels(); delete catalogErrors[vendor]; }
83
+ catch (err) { catalogErrors[vendor] = err.message || String(err); console.warn("[worker-proposal] Could not load " + vendor + " models:", catalogErrors[vendor]); }
75
84
  }
76
85
  }
77
-
78
86
  function modelIsAvailable(options, vendor, model) {
79
87
  if (!model) return true;
80
88
  var models = options.modelsByVendor[vendor] || [];
@@ -83,7 +91,6 @@ function attachWorkerProposal(ctx) {
83
91
  }
84
92
  return false;
85
93
  }
86
-
87
94
  function effortIsAvailable(options, vendor, model, effort) {
88
95
  var capabilities = options.capabilitiesByVendor[vendor] || {};
89
96
  if (capabilities.effort === false) return !effort;
@@ -97,7 +104,6 @@ function attachWorkerProposal(ctx) {
97
104
  }
98
105
  return true;
99
106
  }
100
-
101
107
  function chooseRecommendation(args, session, options) {
102
108
  var installed = options.installedVendors;
103
109
  var vendor = args.recommendedVendor;
@@ -125,12 +131,10 @@ function attachWorkerProposal(ctx) {
125
131
  var effort = yoke.clampEffort(vendor, args.recommendedEffort || "medium") || "";
126
132
  return { vendor: vendor, model: model, effort: effort };
127
133
  }
128
-
129
134
  function skipPermissionsEnabled(session) {
130
135
  return !!(session && (session.permissionMode === "bypassPermissions" ||
131
136
  session.dangerouslySkipPermissions || ctx.dangerouslySkipPermissions));
132
137
  }
133
-
134
138
  function recommendationCanAutoAccept(args, recommendation, options) {
135
139
  var requestedVendor = typeof args.recommendedVendor === "string" ? args.recommendedVendor.trim() : "";
136
140
  var requestedModel = typeof args.recommendedModel === "string" ? args.recommendedModel.trim() : "";
@@ -143,32 +147,12 @@ function attachWorkerProposal(ctx) {
143
147
  return requestedEffort === recommendation.effort &&
144
148
  effortIsAvailable(options, requestedVendor, requestedModel, requestedEffort);
145
149
  }
146
-
147
150
  function autoAcceptanceWs(session) {
148
151
  return {
149
152
  _clayActiveSession: session.localId,
150
153
  _clayUser: session.ownerId ? { id: session.ownerId } : null,
151
154
  };
152
155
  }
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
156
  function updateProposal(session, proposal, patch) {
173
157
  Object.assign(proposal, patch, { updatedAt: Date.now() });
174
158
  sm.saveSessionFile(session);
@@ -177,10 +161,13 @@ function attachWorkerProposal(ctx) {
177
161
  proposalId: proposal.proposalId,
178
162
  }, patch));
179
163
  }
180
-
181
164
  async function propose(args, session) {
182
165
  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." });
166
+ var superseded = proposalControl.pendingProposal(session);
167
+ var supersedeId = typeof args.supersedeProposalId === "string" ? args.supersedeProposalId.trim() : "";
168
+ 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." });
169
+ if (!superseded && supersedeId) return toolResult({ error: "The Split Worker suggestion to supersede is no longer pending." });
170
+ if (superseded && superseded.status !== "pending") return toolResult({ error: "The Split Worker suggestion is already starting and cannot be superseded." });
184
171
  var summary = typeof args.summary === "string" ? args.summary.trim() : "";
185
172
  var plan = typeof args.plan === "string" ? args.plan.trim() : "";
186
173
  var task = typeof args.message === "string" ? args.message.trim() : "";
@@ -193,6 +180,8 @@ function attachWorkerProposal(ctx) {
193
180
  if (!isEligible(session)) {
194
181
  return toolResult({ error: "The Driver session changed while Split Worker runtimes were loading." });
195
182
  }
183
+ var currentPending = proposalControl.pendingProposal(session);
184
+ if (currentPending !== superseded) return toolResult({ error: "The pending Split Worker suggestion changed while runtimes were loading." });
196
185
  var options = proposalOptions();
197
186
  if (options.installedVendors.length === 0) return toolResult({ error: "No coding agent is installed for a Split Worker session." });
198
187
  var recommendation = chooseRecommendation(args, session, options);
@@ -203,12 +192,14 @@ function attachWorkerProposal(ctx) {
203
192
  plan: plan.slice(0, MAX_PLAN_CHARS),
204
193
  message: task,
205
194
  status: "pending",
195
+ createdAt: Date.now(),
206
196
  recommendedVendor: recommendation.vendor,
207
197
  recommendedModel: recommendation.model,
208
198
  recommendedEffort: recommendation.effort,
209
199
  recommendationRationale: rationale.slice(0, MAX_RATIONALE_CHARS),
210
200
  options: options,
211
201
  };
202
+ if (superseded) updateProposal(session, superseded, { status: "superseded", supersededBy: proposal.proposalId });
212
203
  sm.sendAndRecord(session, proposal);
213
204
  if (skipPermissionsEnabled(session) && recommendationCanAutoAccept(args, recommendation, options)) {
214
205
  var accepted = await acceptProposal(session, proposal, {
@@ -230,7 +221,6 @@ function attachWorkerProposal(ctx) {
230
221
  instruction: "The Split Worker suggestion is visible in the chat. End this turn now and wait for the user's decision.",
231
222
  });
232
223
  }
233
-
234
224
  async function proposeReplacement(args, session) {
235
225
  var group = session && store.groupForMember(session.localId);
236
226
  if (ctx.isMate || !isLiveSession(session) || !driverEligibility.isEligibleDriverSession(session, sm) || !group ||
@@ -239,12 +229,18 @@ function attachWorkerProposal(ctx) {
239
229
  }
240
230
  var sourceGroupId = group.id;
241
231
  var sourceWorkerId = group.pair.workerId;
242
- if (hasPendingProposal(session)) return toolResult({ error: "A Split Worker proposal is already awaiting a decision." });
232
+ var superseded = proposalControl.pendingProposal(session);
233
+ var supersedeId = typeof args.supersedeProposalId === "string" ? args.supersedeProposalId.trim() : "";
234
+ 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." });
235
+ if (!superseded && supersedeId) return toolResult({ error: "The Split Worker proposal to supersede is no longer pending." });
236
+ if (superseded && superseded.status !== "pending") return toolResult({ error: "The Split Worker proposal is already starting and cannot be superseded." });
243
237
  var task = typeof args.message === "string" ? args.message.trim() : "";
244
238
  var rationale = typeof args.recommendationRationale === "string" ? args.recommendationRationale.trim() : "";
245
239
  if (!task) return toolResult({ error: "message is required so an accepted replacement delegates exactly once." });
246
240
  if (!rationale) return toolResult({ error: "recommendationRationale is required for the replacement audit trail." });
247
241
  if (task.length > MAX_TASK_CHARS) return toolResult({ error: "The Split Worker task is too long." });
242
+ var normalizedEvaluation = normalizeEvaluation(args.evaluation);
243
+ if (normalizedEvaluation && normalizedEvaluation.error) return toolResult({ error: normalizedEvaluation.error });
248
244
  await ensureModelCatalogs();
249
245
  var liveGroup = store.groupForMember(session.localId);
250
246
  if (!isLiveSession(session) || !driverEligibility.isEligibleDriverSession(session, sm) ||
@@ -252,6 +248,8 @@ function attachWorkerProposal(ctx) {
252
248
  liveGroup.pair.driverId !== session.localId || liveGroup.pair.workerId !== sourceWorkerId) {
253
249
  return toolResult({ error: "The Driver/Split Worker pair changed while replacement runtimes were loading." });
254
250
  }
251
+ var currentPending = proposalControl.pendingProposal(session);
252
+ if (currentPending !== superseded) return toolResult({ error: "The pending Split Worker proposal changed while runtimes were loading." });
255
253
  var options = proposalOptions();
256
254
  if (options.installedVendors.length === 0) return toolResult({ error: "No coding agent is installed for a Split Worker session." });
257
255
  var recommendationArgs = {
@@ -268,6 +266,7 @@ function attachWorkerProposal(ctx) {
268
266
  plan: "1. Preserve the current Worker's session and history\n2. Create the selected replacement runtime\n3. Delegate the next task exactly once",
269
267
  message: task,
270
268
  status: "pending",
269
+ createdAt: Date.now(),
271
270
  recommendedVendor: recommendation.vendor,
272
271
  recommendedModel: recommendation.model,
273
272
  recommendedEffort: recommendation.effort,
@@ -276,8 +275,10 @@ function attachWorkerProposal(ctx) {
276
275
  sourceGroupId: sourceGroupId,
277
276
  sourceWorkerId: sourceWorkerId,
278
277
  interrupt: args.interrupt === true,
279
- evaluation: args.evaluation || null,
278
+ evaluation: normalizedEvaluation ? normalizedEvaluation.value : null,
279
+ transactionId: "replace_" + crypto.randomUUID(),
280
280
  };
281
+ if (superseded) updateProposal(session, superseded, { status: "superseded", supersededBy: proposal.proposalId });
281
282
  sm.sendAndRecord(session, proposal);
282
283
  if (skipPermissionsEnabled(session) && recommendationCanAutoAccept(recommendationArgs, recommendation, options)) {
283
284
  var accepted = await acceptProposal(session, proposal, {
@@ -299,7 +300,6 @@ function attachWorkerProposal(ctx) {
299
300
  instruction: "The replacement configuration is visible in the chat. End this turn now and wait for the user's decision.",
300
301
  });
301
302
  }
302
-
303
303
  function resumeDriver(session, text) {
304
304
  var sdk = ctx.getSdk();
305
305
  if (!sdk) return Promise.reject(new Error("SDK bridge is not ready"));
@@ -335,7 +335,7 @@ function attachWorkerProposal(ctx) {
335
335
  if (result.status === "complete") {
336
336
  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
337
  } 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.";
338
+ 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
339
  } else if (result.status === "running") {
340
340
  followup = "[Split Worker execution is still running]\nUse read_partner to inspect progress before completing the task.";
341
341
  } else {
@@ -398,6 +398,7 @@ function attachWorkerProposal(ctx) {
398
398
  workerModel: model,
399
399
  workerEffort: effort,
400
400
  evaluation: proposal.evaluation || undefined,
401
+ transactionId: proposal.transactionId,
401
402
  }, session);
402
403
  liveGroup = store.groupForMember(session.localId);
403
404
  updateProposal(session, proposal, { status: "running", groupId: liveGroup.id, workerId: replaced.workerSessionId });
@@ -426,6 +427,7 @@ function attachWorkerProposal(ctx) {
426
427
  restoredGroup.pair.workerId === proposal.sourceWorkerId) {
427
428
  proposal.sourceGroupId = restoredGroup.id;
428
429
  }
430
+ proposal.transactionId = "replace_" + crypto.randomUUID();
429
431
  }
430
432
  updateProposal(session, proposal, {
431
433
  status: "pending",
@@ -440,7 +442,7 @@ function attachWorkerProposal(ctx) {
440
442
 
441
443
  async function respondToProposal(ws, msg) {
442
444
  var session = sessionForResponse(ws);
443
- var proposal = findProposal(session, msg.proposalId);
445
+ var proposal = proposalControl.findProposal(session, msg.proposalId);
444
446
  if (!proposal) throw new Error("Split Worker suggestion not found");
445
447
  if (proposal.status !== "pending") throw new Error("Split Worker suggestion has already been resolved");
446
448
  if (!msg.accepted) {
@@ -464,8 +466,9 @@ function attachWorkerProposal(ctx) {
464
466
 
465
467
  function getToolDefs(session, options) {
466
468
  var persistent = !!(options && options.persistent);
469
+ var controlsOnly = !!(options && options.controlsOnly);
467
470
  if (!isEligible(session) && !(persistent && canOffer(session))) return [];
468
- return [{
471
+ var proposalTools = controlsOnly ? [] : [{
469
472
  name: "propose_worker",
470
473
  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
474
  inputSchema: buildShape({
@@ -476,25 +479,33 @@ function attachWorkerProposal(ctx) {
476
479
  recommendedModel: { type: "string", description: "Optional exact Split Worker model id. Omit when uncertain." },
477
480
  recommendedEffort: { type: "string", description: "Optional reasoning effort: minimal, low, medium, high, xhigh, or max." },
478
481
  recommendationRationale: { type: "string", description: "Concise Driver-authored explanation of why the recommended vendor, model, and effort fit this exact task." },
482
+ supersedeProposalId: { type: "string", description: "Exact pending proposal id to resolve as superseded when posting this replacement suggestion." },
479
483
  }, ["summary", "plan", "message", "recommendationRationale"]),
480
484
  handler: function (args) { return propose(args || {}, session); },
481
485
  }];
486
+ return proposalTools.concat(proposalControl.getToolDefs({
487
+ inspect: function () { return toolResult(isLiveSession(session) ? proposalControl.projection(proposalControl.pendingProposal(session)) : { status: "rejected", reason: "The Driver session is no longer live." }); },
488
+ cancel: function (args) {
489
+ if (!isLiveSession(session)) return toolResult({ status: "rejected", proposalId: args.proposalId || null, reason: "The Driver session is no longer live." });
490
+ var proposal = proposalControl.findProposal(session, args.proposalId);
491
+ if (!proposal || proposal.status !== "pending") return toolResult({ status: "rejected", proposalId: args.proposalId || null, reason: "The exact proposal is not pending." });
492
+ updateProposal(session, proposal, { status: "cancelled" });
493
+ return toolResult({ status: "cancelled", proposalId: proposal.proposalId });
494
+ },
495
+ catalog: function () { return ensureModelCatalogs().then(function () {
496
+ return toolResult(runtimeCatalog.build(proposalOptions(), skipPermissionsEnabled(session), function (vendor, model, effort) {
497
+ return ctx.preflightWorkerForDriver ? ctx.preflightWorkerForDriver(session, { workerVendor: vendor, workerModel: model, workerEffort: effort || "" }) : null;
498
+ }));
499
+ }); },
500
+ }));
482
501
  }
483
502
 
484
503
  function getSystemPrompt(session) {
485
504
  if (!isEligible(session) || !driverOrchestration.isHighTierDriverSession(session, sm)) return "";
486
505
  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
506
  }
488
-
489
- return {
490
- getToolDefs: getToolDefs,
491
- getSystemPrompt: getSystemPrompt,
492
- proposeReplacement: proposeReplacement,
493
- handleMessage: handleMessage,
494
- respondToProposal: respondToProposal,
495
- };
507
+ return { getToolDefs: getToolDefs, getSystemPrompt: getSystemPrompt, proposeReplacement: proposeReplacement,
508
+ handleMessage: handleMessage, respondToProposal: respondToProposal,
509
+ proposalStatus: function (session) { return proposalControl.projection(proposalControl.pendingProposal(session)); } };
496
510
  }
497
-
498
- module.exports = {
499
- attachWorkerProposal: attachWorkerProposal,
500
- };
511
+ 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) {
@@ -2256,7 +2273,7 @@ function createProjectContext(opts) {
2256
2273
  var _mateId = path.basename(cwd);
2257
2274
  var _mateCtx = matesModule.buildMateCtx(projectOwnerId);
2258
2275
  // Collect non-mate projects for project registry injection
2259
- var _projectList = (getProjectList() || []).filter(function (p) { return !p.isMate; });
2276
+ var _projectList = (getProjectList(projectOwnerId) || []).filter(function (p) { return !p.isMate; });
2260
2277
  var _enforceOpts = { ctx: _mateCtx, mateId: _mateId, projects: _projectList };
2261
2278
  // Enforce all system sections atomically on startup (single read/write)
2262
2279
  var _selfWrite = false; // suppress watcher when we wrote the file ourselves
@@ -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);
@@ -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;
@@ -0,0 +1,141 @@
1
+ import { escapeHtml } from './utils.js';
2
+ import { iconHtml, refreshIcons } from './icons.js';
3
+
4
+ var projectAccessPopover = null;
5
+
6
+ function closeAccessOnOutside(e) {
7
+ if (projectAccessPopover && !projectAccessPopover.contains(e.target)) closeProjectAccessPopover();
8
+ }
9
+
10
+ function closeAccessOnEscape(e) {
11
+ if (e.key === "Escape") closeProjectAccessPopover();
12
+ }
13
+
14
+ export function closeProjectAccessPopover() {
15
+ if (!projectAccessPopover) return;
16
+ projectAccessPopover.remove();
17
+ projectAccessPopover = null;
18
+ document.removeEventListener("click", closeAccessOnOutside);
19
+ document.removeEventListener("keydown", closeAccessOnEscape);
20
+ }
21
+
22
+ function positionPopover(popover, anchorEl) {
23
+ var rect = anchorEl.getBoundingClientRect();
24
+ popover.style.position = "fixed";
25
+ popover.style.left = (rect.right + 8) + "px";
26
+ popover.style.top = rect.top + "px";
27
+ popover.style.zIndex = "9999";
28
+ var popRect = popover.getBoundingClientRect();
29
+ if (popRect.right > window.innerWidth - 8) popover.style.left = (rect.left - popRect.width - 8) + "px";
30
+ if (popRect.bottom > window.innerHeight - 8) popover.style.top = (window.innerHeight - popRect.height - 8) + "px";
31
+ }
32
+
33
+ function selectedExactUsers(popover) {
34
+ var selected = [];
35
+ popover.querySelectorAll('.project-access-user-item input[type="checkbox"]:checked').forEach(function (checkbox) {
36
+ if (!checkbox.disabled || checkbox.dataset.explicit === "true") selected.push(checkbox.dataset.uid);
37
+ });
38
+ return selected;
39
+ }
40
+
41
+ function renderAccessPopover(popover, slug, access, allUsers) {
42
+ var visibility = access.visibility || "public";
43
+ var isWorktree = access.isWorktree === true;
44
+ var allowedUsers = isWorktree ? (access.worktreeAllowedUsers || []) : (access.allowedUsers || []);
45
+ var inheritedUsers = isWorktree ? (access.parentAllowedUsers || []) : [];
46
+ var ownerId = access.ownerId;
47
+ var selectableUsers = allUsers.filter(function (user) { return user.id !== ownerId; });
48
+ var html = '';
49
+ html += '<div class="project-access-header">';
50
+ html += '<span class="project-access-title">' + (isWorktree ? 'Worktree Access' : 'Project Access') + '</span>';
51
+ html += '<button class="project-access-close" aria-label="Close access settings">&times;</button>';
52
+ html += '</div>';
53
+
54
+ if (isWorktree) {
55
+ var scopeTitle = visibility === "public" ? "Public parent project" : "This worktree only";
56
+ var scopeText = visibility === "public"
57
+ ? "Every authenticated user can access this worktree. Exact grants are retained if the parent becomes private."
58
+ : "Entire-project access is inherited automatically.";
59
+ html += '<div class="project-access-scope">';
60
+ html += '<span class="project-access-scope-icon">' + iconHtml(visibility === "public" ? "globe" : "git-branch") + '</span>';
61
+ html += '<span><strong>' + scopeTitle + '</strong><small>' + scopeText + '</small></span>';
62
+ html += '</div>';
63
+ } else {
64
+ html += '<div class="project-access-section">';
65
+ html += '<label class="project-access-label">Visibility</label>';
66
+ html += '<div class="project-access-vis-row">';
67
+ html += '<button class="project-access-vis-btn' + (visibility === "private" ? ' active' : '') + '" data-vis="private">' + iconHtml("lock") + ' Private</button>';
68
+ html += '<button class="project-access-vis-btn' + (visibility === "public" ? ' active' : '') + '" data-vis="public">' + iconHtml("globe") + ' Public</button>';
69
+ html += '</div></div>';
70
+ }
71
+
72
+ html += '<div class="project-access-section project-access-users-section"' + (!isWorktree && visibility !== "private" ? ' style="display:none"' : '') + '>';
73
+ html += '<label class="project-access-label">' + (isWorktree ? 'People with exact access' : 'Allowed Users') + '</label>';
74
+ html += '<div class="project-access-user-list">';
75
+ for (var i = 0; i < selectableUsers.length; i++) {
76
+ var user = selectableUsers[i];
77
+ var explicit = allowedUsers.indexOf(user.id) !== -1;
78
+ var inherited = inheritedUsers.indexOf(user.id) !== -1;
79
+ html += '<label class="project-access-user-item' + (inherited ? ' is-inherited' : '') + '">';
80
+ html += '<input type="checkbox" data-uid="' + user.id + '" data-explicit="' + (explicit ? 'true' : 'false') + '"' + (explicit || inherited ? ' checked' : '') + (inherited ? ' disabled' : '') + '>';
81
+ html += '<span>' + escapeHtml(user.displayName || user.username || user.id) + '</span>';
82
+ if (inherited) html += '<small>Entire project</small>';
83
+ html += '</label>';
84
+ }
85
+ if (selectableUsers.length === 0) html += '<div class="project-access-empty">No other users</div>';
86
+ html += '</div></div>';
87
+ popover.innerHTML = html;
88
+ refreshIcons();
89
+
90
+ popover.querySelector(".project-access-close").addEventListener("click", closeProjectAccessPopover);
91
+ popover.querySelectorAll(".project-access-vis-btn").forEach(function (button) {
92
+ button.addEventListener("click", function () {
93
+ var visibilityValue = button.dataset.vis;
94
+ popover.querySelectorAll(".project-access-vis-btn").forEach(function (item) { item.classList.remove("active"); });
95
+ button.classList.add("active");
96
+ var usersSection = popover.querySelector(".project-access-users-section");
97
+ if (usersSection) usersSection.style.display = visibilityValue === "private" ? "" : "none";
98
+ fetch("/api/admin/projects/" + encodeURIComponent(slug) + "/visibility", {
99
+ method: "PUT",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({ visibility: visibilityValue }),
102
+ });
103
+ });
104
+ });
105
+ popover.querySelectorAll('.project-access-user-item input[type="checkbox"]').forEach(function (checkbox) {
106
+ checkbox.addEventListener("change", function () {
107
+ fetch("/api/admin/projects/" + encodeURIComponent(slug) + "/users", {
108
+ method: "PUT",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ allowedUsers: selectedExactUsers(popover) }),
111
+ });
112
+ });
113
+ });
114
+ }
115
+
116
+ export function showProjectAccessPopover(anchorEl, slug) {
117
+ closeProjectAccessPopover();
118
+ var popover = document.createElement("div");
119
+ popover.className = "project-access-popover";
120
+ popover.innerHTML = '<div class="project-access-loading">Loading...</div>';
121
+ popover.addEventListener("click", function (e) { e.stopPropagation(); });
122
+ document.body.appendChild(popover);
123
+ projectAccessPopover = popover;
124
+ requestAnimationFrame(function () { positionPopover(popover, anchorEl); });
125
+ setTimeout(function () {
126
+ document.addEventListener("click", closeAccessOnOutside);
127
+ document.addEventListener("keydown", closeAccessOnEscape);
128
+ }, 0);
129
+ Promise.all([
130
+ fetch("/api/admin/projects/" + encodeURIComponent(slug) + "/access").then(function (response) { return response.json(); }),
131
+ fetch("/api/admin/users").then(function (response) { return response.json(); }),
132
+ ]).then(function (results) {
133
+ if (results[0].error || results[1].error) {
134
+ popover.innerHTML = '<div class="project-access-loading">Failed to load</div>';
135
+ return;
136
+ }
137
+ renderAccessPopover(popover, slug, results[0], results[1].users || []);
138
+ }).catch(function () {
139
+ popover.innerHTML = '<div class="project-access-loading">Failed to load</div>';
140
+ });
141
+ }
@@ -0,0 +1,22 @@
1
+ // Groups visible parent projects with their visible worktrees. A worktree whose
2
+ // parent is not in the authorized projection remains a standalone item.
3
+
4
+ export function groupProjects(projects) {
5
+ var source = Array.isArray(projects) ? projects : [];
6
+ var parentSlugs = {};
7
+ var parents = [];
8
+ var wtByParent = {};
9
+ for (var i = 0; i < source.length; i++) {
10
+ if (!source[i].isWorktree) parentSlugs[source[i].slug] = true;
11
+ }
12
+ for (var j = 0; j < source.length; j++) {
13
+ var project = source[j];
14
+ if (project.isWorktree && project.parentSlug && parentSlugs[project.parentSlug]) {
15
+ if (!wtByParent[project.parentSlug]) wtByParent[project.parentSlug] = [];
16
+ wtByParent[project.parentSlug].push(project);
17
+ } else {
18
+ parents.push(project);
19
+ }
20
+ }
21
+ return { parents: parents, wtByParent: wtByParent };
22
+ }