clay-server 4.3.1-beta.2 → 4.3.1-beta.3

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.
@@ -244,7 +244,7 @@ function attachConnection(ctx) {
244
244
  // so vendor badges showed Claude until the next broadcast).
245
245
  sendTo(ws, {
246
246
  type: "session_list",
247
- sessions: sm.mapSessionsForClient(allSessions),
247
+ sessions: sm.mapSessionsForClient(allSessions, undefined, undefined, wsUser ? wsUser.id : null),
248
248
  });
249
249
  if (_splitGroups) _splitGroups.sendConnectionState(ws);
250
250
 
@@ -0,0 +1,101 @@
1
+ var sessionProvenance = require("./session-provenance");
2
+
3
+ // Explicit human deletion of a Driver also removes every Split Worker
4
+ // generation it durably owns. Ownership comes only from the provenance
5
+ // hierarchy: a unique parent origin, the same owner, and a parent that is not
6
+ // itself a Worker. Ambiguous or cross-owner links are never expanded. Close,
7
+ // dissolve, and replacement do not come through here and keep history, and
8
+ // deleting a Worker never removes its Driver or siblings.
9
+
10
+ function resolveDeletionScope(sessions, requested, canDelete) {
11
+ var workersByDriver = sessionProvenance.ownedWorkersByDriver(Array.from(sessions.values()));
12
+ var ids = [];
13
+ var cascadedIds = [];
14
+ var seen = {};
15
+ function add(session, cascaded) {
16
+ if (seen[session.localId]) return;
17
+ seen[session.localId] = true;
18
+ ids.push(session.localId);
19
+ if (cascaded) cascadedIds.push(session.localId);
20
+ }
21
+ for (var j = 0; j < requested.length; j++) {
22
+ var target = requested[j];
23
+ add(target, false);
24
+ if (sessionProvenance.isWorker(target)) continue;
25
+ var owned = workersByDriver[target.localId] || [];
26
+ for (var k = 0; k < owned.length; k++) {
27
+ // The whole scope is authorized before anything is removed.
28
+ if (!canDelete(owned[k])) {
29
+ return { ok: false, error: "You cannot delete this Driver because one of its Split Worker sessions is not accessible to you" };
30
+ }
31
+ add(owned[k], true);
32
+ }
33
+ }
34
+ return { ok: true, ids: ids, cascadedIds: cascadedIds };
35
+ }
36
+
37
+ function attachSessionDelete(ctx) {
38
+ var sm = ctx.sm;
39
+ var usersModule = ctx.usersModule;
40
+ var getProjectAccess = ctx.getProjectAccess || function () { return { visibility: "public" }; };
41
+
42
+ function hasDeletePermission(ws) {
43
+ if (!ws._clayUser) return true;
44
+ return !!usersModule.getEffectivePermissions(ws._clayUser, ctx.osUsers).sessionDelete;
45
+ }
46
+ function accessCheck(ws, access) {
47
+ return function (session) {
48
+ if (!usersModule.isMultiUser()) return true;
49
+ return !!(ws._clayUser && usersModule.canAccessSession(ws._clayUser.id, session, access));
50
+ };
51
+ }
52
+ function releaseRuntime(ids) {
53
+ for (var i = 0; i < ids.length; i++) {
54
+ var session = sm.sessions.get(ids[i]);
55
+ if (!session) continue;
56
+ // TUI sessions: reap the PTY and stop title watchers before the
57
+ // records are wiped so no `claude` process is left orphaned.
58
+ if (ctx.tm && session.mode === "tui" && typeof session.terminalId === "number") {
59
+ try { ctx.tm.close(session.terminalId); } catch (e) {}
60
+ }
61
+ ctx.stopTitleWatcher(session);
62
+ }
63
+ }
64
+ function refuse(ws, text) {
65
+ ctx.sendTo(ws, { type: "error", text: text });
66
+ }
67
+
68
+ function deleteSession(ws, msg) {
69
+ if (!hasDeletePermission(ws)) return refuse(ws, "You do not have permission to delete sessions");
70
+ if (!msg.id || !sm.sessions.has(msg.id)) return;
71
+ var target = sm.sessions.get(msg.id);
72
+ var canDelete = accessCheck(ws, getProjectAccess());
73
+ if (!canDelete(target)) return;
74
+ var scope = resolveDeletionScope(sm.sessions, [target], canDelete);
75
+ if (!scope.ok) return refuse(ws, scope.error);
76
+ releaseRuntime(scope.ids);
77
+ if (scope.ids.length === 1) sm.deleteSession(target.localId, ws);
78
+ else sm.deleteSessionsBulk(scope.ids, ws);
79
+ }
80
+
81
+ function bulkDeleteSessions(ws, msg) {
82
+ if (!Array.isArray(msg.sessionIds) || msg.sessionIds.length === 0) return;
83
+ if (!hasDeletePermission(ws)) return refuse(ws, "You do not have permission to delete sessions");
84
+ var canDelete = accessCheck(ws, { visibility: "public" });
85
+ var requested = [];
86
+ for (var i = 0; i < msg.sessionIds.length; i++) {
87
+ var id = msg.sessionIds[i];
88
+ var target = typeof id === "number" ? sm.sessions.get(id) : null;
89
+ if (target && canDelete(target)) requested.push(target);
90
+ }
91
+ if (requested.length === 0) return;
92
+ var scope = resolveDeletionScope(sm.sessions, requested, canDelete);
93
+ if (!scope.ok) return refuse(ws, scope.error);
94
+ releaseRuntime(scope.ids);
95
+ sm.deleteSessionsBulk(scope.ids, ws);
96
+ }
97
+
98
+ return { deleteSession: deleteSession, bulkDeleteSessions: bulkDeleteSessions };
99
+ }
100
+
101
+ module.exports = { attachSessionDelete: attachSessionDelete, resolveDeletionScope: resolveDeletionScope };
@@ -7,6 +7,7 @@ var updateSnooze = require("./update-snooze");
7
7
  var yoke = require("./yoke");
8
8
  var sessionVisibility = require("./session-visibility");
9
9
  var contextUsageModule = require("./context-usage");
10
+ var sessionDeleteModule = require("./project-session-delete");
10
11
 
11
12
  function vendorSupportsTui(vendor) {
12
13
  var info = yoke.getVendorInfo(vendor);
@@ -337,6 +338,11 @@ function attachSessions(ctx) {
337
338
  }
338
339
  }
339
340
 
341
+ var sessionDelete = sessionDeleteModule.attachSessionDelete({
342
+ sm: sm, usersModule: usersModule, osUsers: osUsers, tm: tm, sendTo: sendTo,
343
+ getProjectAccess: getProjectAccess, stopTitleWatcher: stopTitleWatcher,
344
+ });
345
+
340
346
  // Kick off watchers for any TUI sessions already loaded from disk so
341
347
  // their titles refresh as soon as Claude Code rewrites the jsonl, even
342
348
  // before the user clicks them.
@@ -779,31 +785,7 @@ function attachSessions(ctx) {
779
785
  }
780
786
 
781
787
  if (msg.type === "bulk_delete_sessions") {
782
- if (!Array.isArray(msg.sessionIds) || msg.sessionIds.length === 0) return true;
783
- var deletableIds = [];
784
- for (var di = 0; di < msg.sessionIds.length; di++) {
785
- var bulkId = msg.sessionIds[di];
786
- if (typeof bulkId !== "number") continue;
787
- var bulkTarget = sm.sessions.get(bulkId);
788
- if (!bulkTarget) continue;
789
- if (usersModule.isMultiUser() && ws._clayUser) {
790
- if (!usersModule.canAccessSession(ws._clayUser.id, bulkTarget, { visibility: "public" })) continue;
791
- }
792
- deletableIds.push(bulkId);
793
- }
794
- if (deletableIds.length > 0) {
795
- // TUI sessions: kill their PTYs and stop title watchers before the
796
- // records are wiped.
797
- for (var bdi = 0; bdi < deletableIds.length; bdi++) {
798
- var bdTarget = sm.sessions.get(deletableIds[bdi]);
799
- if (!bdTarget) continue;
800
- if (tm && bdTarget.mode === "tui" && typeof bdTarget.terminalId === "number") {
801
- try { tm.close(bdTarget.terminalId); } catch (e) {}
802
- }
803
- stopTitleWatcher(bdTarget);
804
- }
805
- sm.deleteSessionsBulk(deletableIds, ws);
806
- }
788
+ sessionDelete.bulkDeleteSessions(ws, msg);
807
789
  return true;
808
790
  }
809
791
 
@@ -978,25 +960,7 @@ function attachSessions(ctx) {
978
960
  }
979
961
 
980
962
  if (msg.type === "delete_session") {
981
- if (ws._clayUser) {
982
- var sdPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
983
- if (!sdPerms.sessionDelete) {
984
- sendTo(ws, { type: "error", text: "You do not have permission to delete sessions" });
985
- return true;
986
- }
987
- }
988
- if (msg.id && sm.sessions.has(msg.id)) {
989
- // TUI session: kill the underlying PTY before deleting the session
990
- // record so the `claude` process is reaped and not left orphaned.
991
- var dsTarget = sm.sessions.get(msg.id);
992
- if (usersModule.isMultiUser() && (!ws._clayUser ||
993
- !usersModule.canAccessSession(ws._clayUser.id, dsTarget, getProjectAccess()))) return true;
994
- if (dsTarget && dsTarget.mode === "tui" && typeof dsTarget.terminalId === "number" && tm) {
995
- try { tm.close(dsTarget.terminalId); } catch (e) {}
996
- }
997
- if (dsTarget) stopTitleWatcher(dsTarget);
998
- sm.deleteSession(msg.id, ws);
999
- }
963
+ sessionDelete.deleteSession(ws, msg);
1000
964
  return true;
1001
965
  }
1002
966
 
package/lib/project.js CHANGED
@@ -2568,6 +2568,7 @@ function createProjectContext(opts) {
2568
2568
  function destroy() {
2569
2569
  if (_scheduledMessages) _scheduledMessages.shutdown();
2570
2570
  if (_autonomousRun) _autonomousRun.shutdown();
2571
+ _splitGroups.shutdown();
2571
2572
  _loop.stopTimer();
2572
2573
  _email.destroy();
2573
2574
  stopFileWatch();
@@ -0,0 +1,96 @@
1
+ // Describes the server deletion scope so confirmations never understate it.
2
+ // Deleting a Driver also deletes every Split Worker session it owns,
3
+ // including hidden sessions and previous generations. The server sends that
4
+ // full total as `ownedWorkerCount`; the visible list alone can miss hidden
5
+ // Workers, so it never stands in for the total. When the total is unknown,
6
+ // the wording says so and a dialog is required. Deleting a Worker affects only
7
+ // that Worker.
8
+
9
+ var FULL_SCOPE = "including hidden sessions and previous generations";
10
+
11
+ export function ownedWorkerIds(driverId, sessions) {
12
+ var list = Array.isArray(sessions) ? sessions : [];
13
+ var ids = [];
14
+ for (var i = 0; i < list.length; i++) {
15
+ var session = list[i];
16
+ if (session && session.sessionRole === "worker" && session.parentAvailable && session.parentSessionId === driverId) {
17
+ ids.push(session.id);
18
+ }
19
+ }
20
+ return ids;
21
+ }
22
+
23
+ function findSession(sessionId, sessions) {
24
+ var list = Array.isArray(sessions) ? sessions : [];
25
+ for (var i = 0; i < list.length; i++) if (list[i] && list[i].id === sessionId) return list[i];
26
+ return null;
27
+ }
28
+
29
+ // { worker: true } | { known: true, visible, total } | { known: false, visible }
30
+ function ownership(session, sessions) {
31
+ if (!session) return { known: false, visible: [] };
32
+ if (session.sessionRole === "worker") return { worker: true, visible: [] };
33
+ var visible = ownedWorkerIds(session.id, sessions);
34
+ if (typeof session.ownedWorkerCount !== "number" || session.ownedWorkerCount < 0) return { known: false, visible: visible };
35
+ return { known: true, visible: visible, total: Math.max(session.ownedWorkerCount, visible.length) };
36
+ }
37
+
38
+ function workerPhrase(count) {
39
+ return count + " Split Worker " + (count === 1 ? "session" : "sessions");
40
+ }
41
+
42
+ export function describeSessionDelete(session, sessions) {
43
+ var cached = session ? findSession(session.id, sessions) : null;
44
+ var resolved = cached || (session && session.sessionRole ? session : null);
45
+ var title = (session && session.title) || (cached && cached.title) || "New Session";
46
+ var base = 'Delete "' + title + '"? ';
47
+ var owner = ownership(resolved, sessions);
48
+ if (owner.worker || (owner.known && owner.total === 0)) {
49
+ return { workerCount: 0, requiresDialog: false, message: base + "This session and its history will be permanently removed." };
50
+ }
51
+ if (owner.known) {
52
+ return { workerCount: owner.total, requiresDialog: true,
53
+ message: base + "This Driver and all " + workerPhrase(owner.total) + " it owns, " + FULL_SCOPE +
54
+ ", will be permanently removed with their history." };
55
+ }
56
+ return { workerCount: null, requiresDialog: true,
57
+ message: base + "This session and its history will be permanently removed. If it is a Driver, every Split Worker session it owns, " +
58
+ FULL_SCOPE + ", will also be permanently removed." };
59
+ }
60
+
61
+ export function describeGroupDelete(groupLabel, sessionIds, sessions) {
62
+ var requested = [];
63
+ var seen = new Set();
64
+ var list = Array.isArray(sessionIds) ? sessionIds : [];
65
+ for (var r = 0; r < list.length; r++) {
66
+ if (seen.has(list[r])) continue;
67
+ seen.add(list[r]);
68
+ requested.push(list[r]);
69
+ }
70
+ var ids = requested.slice();
71
+ var hidden = 0;
72
+ var unknown = 0;
73
+ for (var i = 0; i < requested.length; i++) {
74
+ var owner = ownership(findSession(requested[i], sessions), sessions);
75
+ if (owner.worker) continue;
76
+ for (var j = 0; j < owner.visible.length; j++) {
77
+ if (seen.has(owner.visible[j])) continue;
78
+ seen.add(owner.visible[j]);
79
+ ids.push(owner.visible[j]);
80
+ }
81
+ if (owner.known) hidden += owner.total - owner.visible.length;
82
+ else unknown++;
83
+ }
84
+ var cascaded = ids.length - requested.length + hidden;
85
+ var total = ids.length + hidden;
86
+ var noun = total === 1 ? "session" : "sessions";
87
+ var message = 'Clear "' + groupLabel + '"? ';
88
+ if (unknown > 0) {
89
+ message += "At least " + total + " " + noun + " will be permanently removed, plus every Split Worker session owned by a Driver here, " + FULL_SCOPE + ".";
90
+ } else {
91
+ message += total + " " + noun + " will be permanently removed";
92
+ if (cascaded > 0) message += ", including " + workerPhrase(cascaded) + " owned by these Drivers, " + FULL_SCOPE;
93
+ message += ".";
94
+ }
95
+ return { ids: ids, cascadedCount: cascaded, exact: unknown === 0, message: message };
96
+ }
@@ -18,6 +18,7 @@ import { groupedSessionIds } from './split-group-helpers.js';
18
18
  import { defaultVendorUnavailable, resolvePreferredVendor, saveDefaultVendor } from './default-vendor.js';
19
19
  import { openPairDialog } from './split-pair-ui.js';
20
20
  import { renderSessionCreationCta } from './session-creation-cta.js';
21
+ import { describeSessionDelete, describeGroupDelete } from './session-delete-scope.js';
21
22
  import {
22
23
  hierarchyItemMatches,
23
24
  prepareSidebarHierarchy,
@@ -192,7 +193,7 @@ function spawnSessionDeleteParticles(sessionId) {
192
193
  }
193
194
 
194
195
  function confirmDeleteSession(session) {
195
- showConfirm('Delete "' + (session.title || "New Session") + '"? This session and its history will be permanently removed.', function () {
196
+ showConfirm(describeSessionDelete(session, cachedSessions).message, function () {
196
197
  var ws = getWs();
197
198
  if (ws && store.get('connected')) {
198
199
  ws.send(JSON.stringify({ type: "delete_session", id: session.id }));
@@ -271,9 +272,7 @@ function collectItemSessionIds(item) {
271
272
 
272
273
  function confirmDeleteSessionGroup(groupLabel, sessionIds) {
273
274
  if (!Array.isArray(sessionIds) || sessionIds.length === 0) return;
274
- var count = sessionIds.length;
275
- var noun = count === 1 ? "session" : "sessions";
276
- showConfirm('Clear "' + groupLabel + '"? ' + count + " " + noun + ' will be permanently removed.', function () {
275
+ showConfirm(describeGroupDelete(groupLabel, sessionIds, cachedSessions).message, function () {
277
276
  var ws = getWs();
278
277
  if (ws && store.get('connected')) {
279
278
  ws.send(JSON.stringify({ type: "bulk_delete_sessions", sessionIds: sessionIds }));
@@ -319,6 +318,13 @@ function appendSessionCloseButton(el, session) {
319
318
  closeBtn.addEventListener("click", function (e) {
320
319
  e.preventDefault();
321
320
  e.stopPropagation();
321
+ // A deletion that removes or may remove Split Workers always gets the
322
+ // explicit dialog instead of the two-click armed shortcut.
323
+ if (describeSessionDelete(session, cachedSessions).requiresDialog) {
324
+ clearArmedSessionDelete();
325
+ confirmDeleteSession(session);
326
+ return;
327
+ }
322
328
  if (armedDeleteSessionId === session.id) {
323
329
  clearArmedSessionDelete();
324
330
  deleteSessionImmediately(session);
@@ -110,9 +110,25 @@ function hierarchyFor(sessions) {
110
110
  return result;
111
111
  }
112
112
 
113
+ // Every Worker the hierarchy links to each Driver, keyed by Driver local id.
114
+ // This is the exact scope a Driver deletion cascades to, including hidden
115
+ // sessions and replaced generations.
116
+ function ownedWorkersByDriver(sessions) {
117
+ var hierarchy = hierarchyFor(sessions);
118
+ var result = {};
119
+ for (var i = 0; i < sessions.length; i++) {
120
+ var node = hierarchy[sessions[i].localId];
121
+ if (!node || node.role !== "worker" || node.parentSessionId == null) continue;
122
+ if (!result[node.parentSessionId]) result[node.parentSessionId] = [];
123
+ result[node.parentSessionId].push(sessions[i]);
124
+ }
125
+ return result;
126
+ }
127
+
113
128
  module.exports = {
114
129
  ensureOrigin: ensureOrigin,
115
130
  hierarchyFor: hierarchyFor,
131
+ ownedWorkersByDriver: ownedWorkersByDriver,
116
132
  isWorker: isWorker,
117
133
  markWorker: markWorker,
118
134
  metadata: metadata,
@@ -0,0 +1,113 @@
1
+ var splitRoles = require("./session-split-group-roles");
2
+ var durableAnchors = require("./session-split-group-anchors");
3
+
4
+ // Session deletion is irreversible, so its split-group effect is applied to
5
+ // live membership before persistence and is never rolled back. A failed
6
+ // write leaves the durable record pending: a bounded-backoff retry, or the
7
+ // next successful save of any group, writes the authoritative live state.
8
+ var DEFAULT_RETRY_DELAYS_MS = [1000, 5000, 15000, 60000];
9
+
10
+ function refreshLiveAnchors(group, sessions) {
11
+ var member = durableAnchors.versionedMemberAnchors(group, sessions);
12
+ var pair = durableAnchors.versionedPairAnchors(group, sessions);
13
+ if (member) group.memberAnchors = member; else delete group.memberAnchors;
14
+ if (pair) group.pairAnchors = pair; else delete group.pairAnchors;
15
+ }
16
+
17
+ function removeDeletedMember(groups, localId, sessions) {
18
+ var index = -1;
19
+ for (var i = 0; i < groups.length; i++) {
20
+ if (groups[i].members.indexOf(localId) !== -1) { index = i; break; }
21
+ }
22
+ if (index === -1) return null;
23
+ var group = groups[index];
24
+ var normalized = splitRoles.normalizePair(group.pair, group.members);
25
+ if (normalized.ok && normalized.kind === "versioned" && normalized.driverId !== localId &&
26
+ normalized.workerIds.length > 1) {
27
+ var remaining = normalized.workerIds.filter(function (id) { return id !== localId; });
28
+ group.members = [normalized.driverId].concat(remaining);
29
+ group.pair = { version: 2, driverId: normalized.driverId, workerIds: remaining };
30
+ // Clients address Workers through these anchors by index, so they must
31
+ // match the reduced roles even while the durable write is pending.
32
+ refreshLiveAnchors(group, sessions);
33
+ return { group: group, dissolved: false };
34
+ }
35
+ groups.splice(index, 1);
36
+ return { group: group, dissolved: true };
37
+ }
38
+
39
+ function attachDeletionCleanup(opts) {
40
+ var delays = Array.isArray(opts.retryDelaysMs) && opts.retryDelaysMs.length ?
41
+ opts.retryDelaysMs.slice() : DEFAULT_RETRY_DELAYS_MS;
42
+ var setTimer = opts.setTimer || setTimeout;
43
+ var clearTimer = opts.clearTimer || clearTimeout;
44
+ var logger = opts.logger || console;
45
+ var pending = null;
46
+ var timer = null;
47
+ var stopped = false;
48
+
49
+ function cancelTimer() {
50
+ if (timer) clearTimer(timer);
51
+ timer = null;
52
+ }
53
+ function schedule() {
54
+ cancelTimer();
55
+ if (stopped || !pending) return;
56
+ timer = setTimer(retry, delays[Math.min(pending.attempts - 1, delays.length - 1)]);
57
+ if (timer && typeof timer.unref === "function") timer.unref();
58
+ }
59
+ function recordFailure(reason, error) {
60
+ var message = error && error.message || String(error);
61
+ if (!pending) pending = { reason: reason, attempts: 0, since: Date.now(), lastError: null };
62
+ pending.attempts++;
63
+ pending.lastError = message;
64
+ logger.error("[split-groups] Durable split group state is pending after " + pending.reason +
65
+ " (attempt " + pending.attempts + "): " + message);
66
+ schedule();
67
+ }
68
+ function saved() {
69
+ if (!pending) return;
70
+ logger.log("[split-groups] Durable split group state recovered after " + pending.reason);
71
+ pending = null;
72
+ cancelTimer();
73
+ }
74
+ function persist(reason) {
75
+ try { opts.save(); return true; }
76
+ catch (error) { recordFailure(reason, error); return false; }
77
+ }
78
+ function retry() {
79
+ timer = null;
80
+ if (stopped || !pending) return;
81
+ persist(pending.reason);
82
+ }
83
+ function sessionDeleted(localId) {
84
+ var change = removeDeletedMember(opts.groups(), localId, opts.sessions);
85
+ if (!change) return false;
86
+ persist("session deletion");
87
+ opts.broadcast();
88
+ opts.onPairChanged(change.group);
89
+ return true;
90
+ }
91
+ function retryNow() {
92
+ if (!pending) return true;
93
+ return persist(pending.reason);
94
+ }
95
+ function status() {
96
+ if (!pending) return { pending: false };
97
+ return { pending: true, reason: pending.reason, attempts: pending.attempts,
98
+ since: pending.since, lastError: pending.lastError, retryScheduled: !!timer };
99
+ }
100
+ function shutdown() {
101
+ stopped = true;
102
+ cancelTimer();
103
+ }
104
+
105
+ return { sessionDeleted: sessionDeleted, recordFailure: recordFailure, saved: saved,
106
+ retryNow: retryNow, status: status, shutdown: shutdown };
107
+ }
108
+
109
+ module.exports = {
110
+ attachDeletionCleanup: attachDeletionCleanup,
111
+ removeDeletedMember: removeDeletedMember,
112
+ DEFAULT_RETRY_DELAYS_MS: DEFAULT_RETRY_DELAYS_MS,
113
+ };
@@ -2,6 +2,7 @@ var fs = require("fs");
2
2
  var path = require("path");
3
3
  var anchors = require("./session-split-group-anchors");
4
4
  var roles = require("./session-split-group-roles");
5
+ var recovery = require("./session-split-group-recovery");
5
6
 
6
7
  var ANCHOR_KEYS = ["memberCliIds", "memberOriginIds", "pairCliIds", "pairOriginIds", "memberAnchors", "pairAnchors"];
7
8
 
@@ -9,6 +10,7 @@ function attachPersistence(opts) {
9
10
  var sessions = opts.sessions;
10
11
  var groupsFile = path.join(opts.sessionsDir, "split-groups.json");
11
12
  var preserved = [];
13
+ var loadRewriteError = null;
12
14
 
13
15
  function anchorPair(group) {
14
16
  if (!group.pair) return;
@@ -116,6 +118,12 @@ function attachPersistence(opts) {
116
118
  var versioned = !!(normalized && normalized.ok && normalized.kind === "versioned");
117
119
  if (versioned && !opts.versionedEnabled) { preserved.push(group); return false; }
118
120
  var resolved = versioned ? resolveVersioned(group, normalized) : resolveLegacy(group);
121
+ if (!resolved && versioned) {
122
+ resolved = recovery.repairDeletedMembers(group, normalized, sessions, {
123
+ loadUncertain: typeof opts.sessionLoadUncertain === "function" && opts.sessionLoadUncertain() === true,
124
+ });
125
+ }
126
+ if (resolved && resolved.drop) return false;
119
127
  if (!resolved) {
120
128
  if (versioned || group && roles.isUnsupportedPair(group.pair)) preserved.push(group);
121
129
  return false;
@@ -126,11 +134,16 @@ function attachPersistence(opts) {
126
134
  return true;
127
135
  });
128
136
  if (groups.length + preserved.length !== loaded.length) rewrite = true;
129
- if (rewrite) save(groups);
137
+ loadRewriteError = null;
138
+ // A storage outage must not discard groups that resolved or were
139
+ // repaired in memory; the store retries the rewrite.
140
+ if (rewrite) {
141
+ try { save(groups); } catch (error) { loadRewriteError = error; }
142
+ }
130
143
  return groups;
131
144
  }
132
145
 
133
- return { load: load, save: save };
146
+ return { load: load, save: save, loadRewriteError: function () { return loadRewriteError; } };
134
147
  }
135
148
 
136
149
  module.exports = { attachPersistence: attachPersistence };
@@ -0,0 +1,87 @@
1
+ var roles = require("./session-split-group-roles");
2
+
3
+ // Restart repair for a Version 2 record whose deletion cleanup never reached
4
+ // disk. Only anchors that no live session carries at all are treated as
5
+ // deleted. Any ambiguous or partially matching identity, owner mismatch, or
6
+ // disagreement between member and pair anchors leaves the record untouched so
7
+ // the ordinary preserve-on-failure path still applies.
8
+
9
+ function value(input) {
10
+ return typeof input === "string" && input ? input : null;
11
+ }
12
+
13
+ function sameAnchor(left, right) {
14
+ return !!(left && right && value(left.cli) === value(right.cli) && value(left.origin) === value(right.origin));
15
+ }
16
+
17
+ function classify(sessions, anchor) {
18
+ var cli = value(anchor && anchor.cli);
19
+ var origin = value(anchor && anchor.origin);
20
+ if (!cli && !origin) return { state: "invalid" };
21
+ var matches = [];
22
+ sessions.forEach(function (session) {
23
+ if ((cli && session.cliSessionId === cli) || (origin && session.sessionOriginId === origin)) matches.push(session);
24
+ });
25
+ if (matches.length === 0) return { state: "absent" };
26
+ var only = matches[0];
27
+ if (matches.length === 1 && (!cli || only.cliSessionId === cli) && (!origin || only.sessionOriginId === origin)) {
28
+ return { state: "exact", session: only };
29
+ }
30
+ return { state: "conflict" };
31
+ }
32
+
33
+ function agreedAnchors(group, normalized) {
34
+ var members = group.memberAnchors;
35
+ var pair = group.pairAnchors;
36
+ if (!members || members.version !== 2 || !Array.isArray(members.members)) return null;
37
+ if (!pair || pair.version !== 2 || !pair.driver || !Array.isArray(pair.workers)) return null;
38
+ if (pair.workers.length !== normalized.workerIds.length || members.members.length !== group.members.length) return null;
39
+ if (members.members.length !== pair.workers.length + 1 || !sameAnchor(members.members[0], pair.driver)) return null;
40
+ for (var i = 0; i < pair.workers.length; i++) {
41
+ if (!sameAnchor(members.members[i + 1], pair.workers[i])) return null;
42
+ }
43
+ return { driver: pair.driver, workers: pair.workers };
44
+ }
45
+
46
+ // Returns null when no safe repair applies, { drop: true } when the stored
47
+ // Driver or every Worker was deleted, or { changed: true } after reducing the
48
+ // record to its exact surviving Driver and Workers. An absent anchor is
49
+ // evidence of deletion only when every session record was read; after any
50
+ // unreadable or unparseable record the stored group is preserved unchanged.
51
+ function repairDeletedMembers(group, normalized, sessions, options) {
52
+ if (typeof group.id !== "string" || !group.id || !normalized.ok || normalized.kind !== "versioned") return null;
53
+ var agreed = agreedAnchors(group, normalized);
54
+ if (!agreed) return null;
55
+ var ownerId = group.ownerId || null;
56
+ var driver = classify(sessions, agreed.driver);
57
+ var workers = agreed.workers.map(function (anchor) { return classify(sessions, anchor); });
58
+ var all = [driver].concat(workers);
59
+ var absent = 0;
60
+ var seen = [];
61
+ for (var i = 0; i < all.length; i++) {
62
+ if (all[i].state === "absent") { absent++; continue; }
63
+ if (all[i].state !== "exact") return null;
64
+ if ((all[i].session.ownerId || null) !== ownerId || seen.indexOf(all[i].session) !== -1) return null;
65
+ seen.push(all[i].session);
66
+ }
67
+ if (absent === 0 || (options && options.loadUncertain)) return null;
68
+ var survivors = [];
69
+ var survivorAnchors = [];
70
+ for (var j = 0; j < workers.length; j++) {
71
+ if (workers[j].state !== "exact") continue;
72
+ survivors.push(workers[j].session);
73
+ survivorAnchors.push(agreed.workers[j]);
74
+ }
75
+ if (driver.state !== "exact" || survivors.length === 0) return { drop: true };
76
+ var memberIds = [driver.session.localId].concat(survivors.map(function (session) { return session.localId; }));
77
+ var nextPair = { version: 2, driverId: driver.session.localId, workerIds: memberIds.slice(1) };
78
+ var checked = roles.normalizePair(nextPair, memberIds);
79
+ if (!checked.ok || checked.kind !== "versioned") return null;
80
+ group.members = memberIds;
81
+ group.pair = nextPair;
82
+ group.memberAnchors = { version: 2, members: [agreed.driver].concat(survivorAnchors) };
83
+ group.pairAnchors = { version: 2, driver: agreed.driver, workers: survivorAnchors };
84
+ return { changed: true };
85
+ }
86
+
87
+ module.exports = { repairDeletedMembers: repairDeletedMembers, classifyAnchor: classify };
@@ -4,6 +4,7 @@ var splitRoles = require("./session-split-group-roles");
4
4
  var versionedStore = require("./session-split-group-v2-store");
5
5
  var multiWorkerFeature = require("./multi-worker-feature");
6
6
  var groupPersistence = require("./session-split-group-persistence");
7
+ var deletionCleanup = require("./session-split-group-cleanup");
7
8
  function truncateTitle(title) {
8
9
  var value = String(title || "New Session");
9
10
  return value.length > 20 ? value.slice(0, 19) + "…" : value;
@@ -20,7 +21,15 @@ function createSplitGroupStore(opts) {
20
21
  var stagedTransfers = [];
21
22
  var versionedEnabled = multiWorkerFeature.isEnabled(opts.multiWorkerFeature);
22
23
  var persistence = groupPersistence.attachPersistence(Object.assign({}, opts, { versionedEnabled: versionedEnabled }));
23
- function save() { return persistence.save(groups); }
24
+ var cleanup = deletionCleanup.attachDeletionCleanup({
25
+ sessions: sessions, groups: function () { return groups; }, save: save,
26
+ broadcast: function () { broadcast(); }, onPairChanged: function (group) { onPairChanged(group); },
27
+ retryDelaysMs: opts.persistRetryDelaysMs, setTimer: opts.setTimer, clearTimer: opts.clearTimer, logger: opts.logger,
28
+ });
29
+ function save() {
30
+ persistence.save(groups);
31
+ cleanup.saved();
32
+ }
24
33
  function isMultiUser() {
25
34
  return !!(usersModule && usersModule.isMultiUser && usersModule.isMultiUser());
26
35
  }
@@ -354,33 +363,8 @@ function createSplitGroupStore(opts) {
354
363
  return { ok: true, group: removed };
355
364
  }
356
365
 
357
- function dissolveBySession(localId) {
358
- var index = groups.findIndex(function (group) { return group.members.indexOf(localId) !== -1; });
359
- if (index === -1) return false;
360
- var group = groups[index];
361
- var normalized = splitRoles.normalizePair(group.pair, group.members);
362
- var beforeMembers = group.members.slice();
363
- var beforePair = group.pair && Object.assign({}, group.pair);
364
- if (beforePair && Array.isArray(beforePair.workerIds)) beforePair.workerIds = beforePair.workerIds.slice();
365
- var removed = null;
366
- if (normalized.ok && normalized.kind === "versioned" && normalized.driverId !== localId &&
367
- normalized.workerIds.length > 1) {
368
- var remaining = normalized.workerIds.filter(function (id) { return id !== localId; });
369
- group.members = [normalized.driverId].concat(remaining);
370
- group.pair = { version: 2, driverId: normalized.driverId, workerIds: remaining };
371
- } else {
372
- removed = groups.splice(index, 1)[0];
373
- }
374
- try { save(); }
375
- catch (error) {
376
- if (removed) groups.splice(index, 0, removed);
377
- else { group.members = beforeMembers; group.pair = beforePair; }
378
- return false;
379
- }
380
- broadcast();
381
- onPairChanged(group);
382
- return true;
383
- }
366
+ // Deletion cleanup is irreversible; see session-split-group-cleanup.js.
367
+ function dissolveBySession(localId) { return cleanup.sessionDeleted(localId); }
384
368
 
385
369
  function refreshAnchors(localId) {
386
370
  var group = groupForMember(localId);
@@ -457,6 +441,7 @@ function createSplitGroupStore(opts) {
457
441
  changed: versionedChanged,
458
442
  });
459
443
  groups = persistence.load();
444
+ if (persistence.loadRewriteError()) cleanup.recordFailure("startup rewrite", persistence.loadRewriteError());
460
445
  return { create: create, createOwned: createOwned, dissolveOwned: dissolveOwned,
461
446
  beginOwnedDriverTransfer: beginOwnedDriverTransfer, commitOwnedDriverTransfer: commitOwnedDriverTransfer,
462
447
  rollbackOwnedDriverTransfer: rollbackOwnedDriverTransfer,
@@ -464,6 +449,7 @@ function createSplitGroupStore(opts) {
464
449
  addWorker: versionedMutations.addWorker, replaceWorker: versionedMutations.replaceWorker,
465
450
  removeWorker: versionedMutations.removeWorker,
466
451
  refreshAutoName: refreshAutoName, refreshAnchors: refreshAnchors,
452
+ persistenceStatus: cleanup.status, retryPersistence: cleanup.retryNow, shutdown: cleanup.shutdown,
467
453
  listFor: listFor, groupForMember: groupForMember, get groups() { return groups; } };
468
454
  }
469
455
 
@@ -475,6 +461,7 @@ function attachSplitGroups(ctx) {
475
461
  broadcast: broadcast,
476
462
  onPairChanged: ctx.onPairChanged,
477
463
  multiWorkerFeature: ctx.multiWorkerFeature,
464
+ sessionLoadUncertain: ctx.sm.sessionLoadUncertain,
478
465
  });
479
466
 
480
467
  function sendState(ws) {
@@ -503,7 +490,7 @@ function attachSplitGroups(ctx) {
503
490
  ctx.sm.setOnSessionDeleted(store.dissolveBySession);
504
491
  ctx.sm.setOnSessionRenamed(store.refreshAutoName);
505
492
  ctx.sm.setOnSessionIdentityAssigned(store.refreshAnchors);
506
- return { handleMessage: handleMessage, sendConnectionState: sendState, store: store };
493
+ return { handleMessage: handleMessage, sendConnectionState: sendState, store: store, shutdown: store.shutdown };
507
494
  }
508
495
 
509
496
  module.exports = { attachSplitGroups: attachSplitGroups, createSplitGroupStore: createSplitGroupStore, autoGroupName: autoGroupName };
package/lib/sessions.js CHANGED
@@ -271,9 +271,16 @@ function createSessionManager(opts) {
271
271
  return null;
272
272
  }
273
273
 
274
+ // Records that could not be read or parsed may still be live sessions.
275
+ // Split-group recovery reads this so it never treats them as deleted.
276
+ var sessionLoadUncertain = false;
277
+
274
278
  function loadSessions() {
275
279
  var files;
276
- try { files = fs.readdirSync(sessionsDir); } catch { return; }
280
+ try { files = fs.readdirSync(sessionsDir); } catch (e) {
281
+ if (!e || e.code !== "ENOENT") sessionLoadUncertain = true;
282
+ return;
283
+ }
277
284
 
278
285
  // Clean up stale temp files from interrupted atomic writes
279
286
  for (var ti = 0; ti < files.length; ti++) {
@@ -286,14 +293,14 @@ function createSessionManager(opts) {
286
293
  for (var i = 0; i < files.length; i++) {
287
294
  if (!files[i].endsWith(".jsonl")) continue;
288
295
  var content;
289
- try { content = fs.readFileSync(path.join(sessionsDir, files[i]), "utf8"); } catch { continue; }
296
+ try { content = fs.readFileSync(path.join(sessionsDir, files[i]), "utf8"); } catch { sessionLoadUncertain = true; continue; }
290
297
  var lines = content.trim().split("\n");
291
298
  if (lines.length === 0) continue;
292
299
 
293
300
  var meta;
294
- try { meta = JSON.parse(lines[0]); } catch { continue; }
295
- var pendingOrigin = typeof meta.sessionOriginId === "string" && meta.sessionOriginId.trim() && meta.sessionOriginId.length <= 128;
296
- if (meta.type !== "meta" || (!meta.cliSessionId && !pendingOrigin)) continue;
301
+ try { meta = JSON.parse(lines[0]); } catch { sessionLoadUncertain = true; continue; }
302
+ var pendingOrigin = meta && typeof meta.sessionOriginId === "string" && meta.sessionOriginId.trim() && meta.sessionOriginId.length <= 128;
303
+ if (!meta || meta.type !== "meta" || (!meta.cliSessionId && !pendingOrigin)) { sessionLoadUncertain = true; continue; }
297
304
 
298
305
  var history = [];
299
306
  for (var j = 1; j < lines.length; j++) {
@@ -632,11 +639,25 @@ function createSessionManager(opts) {
632
639
  };
633
640
  }
634
641
 
635
- function mapSessionsForClient(source, clientActiveId, wsUnread) {
642
+ // Counted over every session, including hidden ones, so delete
643
+ // confirmations can state the full cascade. In multi-user mode only the
644
+ // Driver's own owner receives it: another viewer could otherwise infer
645
+ // hidden or private Workers, and a missing count means "unknown" to the
646
+ // client, never zero. viewerId must come from the authenticated socket.
647
+ function ownedWorkerCountFor(session, node, owned, viewerId) {
648
+ if (node.role !== "driver") return 0;
649
+ if (users.isMultiUser() && (!viewerId || (session.ownerId || null) !== viewerId)) return undefined;
650
+ return owned[session.localId] ? owned[session.localId].length : 0;
651
+ }
652
+
653
+ function mapSessionsForClient(source, clientActiveId, wsUnread, viewerId) {
636
654
  var hierarchy = sessionProvenance.hierarchyFor(source);
655
+ var owned = sessionProvenance.ownedWorkersByDriver([...sessions.values()]);
637
656
  return source.map(function (session) {
638
657
  var mapped = mapSessionForClient(session, clientActiveId, wsUnread);
639
658
  var node = hierarchy[session.localId];
659
+ var ownedCount = ownedWorkerCountFor(session, node, owned, viewerId);
660
+ if (ownedCount !== undefined) mapped.ownedWorkerCount = ownedCount;
640
661
  mapped.sessionRole = node.role;
641
662
  mapped.parentSessionId = node.parentSessionId;
642
663
  mapped.parentAvailable = node.parentAvailable;
@@ -667,7 +688,7 @@ function createSessionManager(opts) {
667
688
  if (ws.readyState === 1) {
668
689
  ws.send(JSON.stringify({
669
690
  type: "session_list",
670
- sessions: mapSessionsForClient(filtered, clientActiveId, wsUnread),
691
+ sessions: mapSessionsForClient(filtered, clientActiveId, wsUnread, ws._clayUser ? ws._clayUser.id : null),
671
692
  }));
672
693
  }
673
694
  });
@@ -1389,6 +1410,7 @@ function createSessionManager(opts) {
1389
1410
  set codexWebSearch(v) { codexWebSearch = v; },
1390
1411
  sessions: sessions,
1391
1412
  sessionsDir: sessionsDir,
1413
+ sessionLoadUncertain: function () { return sessionLoadUncertain; },
1392
1414
  setOnSessionDeleted: function (fn) { onSessionDeleted = fn || function () {}; },
1393
1415
  addOnSessionDeleted: function (fn) { if (typeof fn === "function") sessionDeletedListeners.push(fn); },
1394
1416
  addOnSessionViewed: function (fn) { if (typeof fn === "function") sessionViewedListeners.push(fn); },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.3.1-beta.2",
3
+ "version": "4.3.1-beta.3",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"