clay-server 2.47.0-beta.6 → 3.0.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.js +6 -27
  2. package/lib/project-connection.js +21 -8
  3. package/lib/project-session-pair.js +312 -0
  4. package/lib/project-session-spawn.js +8 -4
  5. package/lib/project-sessions.js +108 -10
  6. package/lib/project-user-message.js +18 -8
  7. package/lib/project.js +33 -0
  8. package/lib/public/app.js +20 -0
  9. package/lib/public/css/menus.css +83 -1
  10. package/lib/public/css/messages.css +14 -0
  11. package/lib/public/css/pane.css +420 -0
  12. package/lib/public/css/sidebar.css +40 -0
  13. package/lib/public/css/sticky-notes.css +2 -0
  14. package/lib/public/index.html +4 -0
  15. package/lib/public/modules/app-connection.js +6 -0
  16. package/lib/public/modules/app-favicon.js +5 -0
  17. package/lib/public/modules/app-header.js +101 -6
  18. package/lib/public/modules/app-messages.js +59 -6
  19. package/lib/public/modules/app-panels.js +49 -27
  20. package/lib/public/modules/app-rendering.js +11 -4
  21. package/lib/public/modules/pane-bridge.js +41 -0
  22. package/lib/public/modules/pane-session.js +18 -0
  23. package/lib/public/modules/sidebar-sessions.js +175 -1
  24. package/lib/public/modules/sidebar.js +2 -0
  25. package/lib/public/modules/split-group-helpers.js +18 -0
  26. package/lib/public/modules/split-pair-ui.js +324 -0
  27. package/lib/public/modules/split-view.js +499 -0
  28. package/lib/public/modules/sticky-notes.js +32 -91
  29. package/lib/public/style.css +1 -0
  30. package/lib/sdk-bridge.js +111 -20
  31. package/lib/sdk-message-processor.js +2 -2
  32. package/lib/server.js +24 -4
  33. package/lib/session-hygiene.js +100 -0
  34. package/lib/session-pair-mcp-server.js +28 -0
  35. package/lib/session-split-groups.js +298 -0
  36. package/lib/sessions.js +163 -34
  37. package/lib/ws-request.js +14 -0
  38. package/lib/ws-schema.js +13 -0
  39. package/lib/yoke/adapters/claude.js +32 -0
  40. package/lib/yoke/adapters/codex.js +100 -77
  41. package/lib/yoke/adapters/kiro.js +34 -35
  42. package/lib/yoke/codex-app-server.js +56 -6
  43. package/lib/yoke/index.js +1 -0
  44. package/lib/yoke/skill-discovery.js +196 -0
  45. package/lib/yoke/vendor-registry.js +34 -0
  46. package/package.json +2 -2
@@ -0,0 +1,298 @@
1
+ var crypto = require("crypto");
2
+ var fs = require("fs");
3
+ var path = require("path");
4
+
5
+ function truncateTitle(title) {
6
+ var value = String(title || "New Session");
7
+ return value.length > 20 ? value.slice(0, 19) + "…" : value;
8
+ }
9
+
10
+ function autoGroupName(leftTitle, rightTitle) {
11
+ return truncateTitle(leftTitle) + " | " + truncateTitle(rightTitle);
12
+ }
13
+
14
+ function createSplitGroupStore(opts) {
15
+ var sessions = opts.sessions;
16
+ var groupsFile = path.join(opts.sessionsDir, "split-groups.json");
17
+ var usersModule = opts.usersModule;
18
+ var broadcast = opts.broadcast || function () {};
19
+ var groups = [];
20
+
21
+ // localIds are reassigned on every daemon restart (loadSessions renumbers
22
+ // by createdAt), so persisted member localIds go stale as soon as the
23
+ // session set changes. cliSessionIds are the durable anchor: keep them on
24
+ // every record and remap members from them at load.
25
+ function currentCliIds(group) {
26
+ var left = sessions.get(group.members[0]);
27
+ var right = sessions.get(group.members[1]);
28
+ var prev = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
29
+ return [
30
+ (left && left.cliSessionId) || prev[0] || null,
31
+ (right && right.cliSessionId) || prev[1] || null,
32
+ ];
33
+ }
34
+
35
+ function refreshPairAnchors(group) {
36
+ if (!group.pair) return;
37
+ var driver = sessions.get(group.pair.driverId);
38
+ var worker = sessions.get(group.pair.workerId);
39
+ group.pairCliIds = [
40
+ (driver && driver.cliSessionId) || (group.pairCliIds && group.pairCliIds[0]) || null,
41
+ (worker && worker.cliSessionId) || (group.pairCliIds && group.pairCliIds[1]) || null,
42
+ ];
43
+ }
44
+
45
+ function sessionByCliId(cliId) {
46
+ if (!cliId) return null;
47
+ var found = null;
48
+ sessions.forEach(function (s) {
49
+ if (s.cliSessionId === cliId) found = s;
50
+ });
51
+ return found;
52
+ }
53
+
54
+ function save() {
55
+ for (var gi = 0; gi < groups.length; gi++) {
56
+ groups[gi].memberCliIds = currentCliIds(groups[gi]);
57
+ refreshPairAnchors(groups[gi]);
58
+ }
59
+ var tmp = groupsFile + ".tmp." + process.pid;
60
+ fs.mkdirSync(opts.sessionsDir, { recursive: true });
61
+ fs.writeFileSync(tmp, JSON.stringify(groups, null, 2) + "\n");
62
+ if (process.platform !== "win32") {
63
+ try { fs.chmodSync(tmp, 0o600); } catch (e) {}
64
+ }
65
+ fs.renameSync(tmp, groupsFile);
66
+ }
67
+
68
+ function validRecord(group) {
69
+ return group && typeof group.id === "string" && Array.isArray(group.members) &&
70
+ group.members.length === 2 && group.members[0] !== group.members[1] &&
71
+ Number.isInteger(group.members[0]) && Number.isInteger(group.members[1]) &&
72
+ sessions.has(group.members[0]) && sessions.has(group.members[1]);
73
+ }
74
+
75
+ function load() {
76
+ var loaded = [];
77
+ var shouldRewrite = false;
78
+ try {
79
+ loaded = JSON.parse(fs.readFileSync(groupsFile, "utf8"));
80
+ if (!Array.isArray(loaded)) loaded = [];
81
+ } catch (e) {
82
+ if (e.code !== "ENOENT") shouldRewrite = true;
83
+ }
84
+ var claimed = {};
85
+ groups = loaded.filter(function (group) {
86
+ // Remap members from the durable cliSessionIds; the stored localIds
87
+ // are only trusted for legacy records that predate memberCliIds.
88
+ if (group && Array.isArray(group.memberCliIds)) {
89
+ var left = sessionByCliId(group.memberCliIds[0]);
90
+ var right = sessionByCliId(group.memberCliIds[1]);
91
+ if (!left || !right || left === right) return false;
92
+ if (group.members[0] !== left.localId || group.members[1] !== right.localId) {
93
+ group.members = [left.localId, right.localId];
94
+ shouldRewrite = true;
95
+ }
96
+ }
97
+ if (group && group.pair && Array.isArray(group.pairCliIds)) {
98
+ var driver = sessionByCliId(group.pairCliIds[0]);
99
+ var worker = sessionByCliId(group.pairCliIds[1]);
100
+ if (driver && worker) {
101
+ group.pair.driverId = driver.localId;
102
+ group.pair.workerId = worker.localId;
103
+ }
104
+ }
105
+ if (group && group.pair) {
106
+ var pairValid = Array.isArray(group.members) && group.pair.driverId !== group.pair.workerId &&
107
+ group.members.indexOf(group.pair.driverId) !== -1 &&
108
+ group.members.indexOf(group.pair.workerId) !== -1;
109
+ if (!pairValid) return false;
110
+ }
111
+ if (!validRecord(group) || claimed[group.members[0]] || claimed[group.members[1]]) return false;
112
+ claimed[group.members[0]] = true;
113
+ claimed[group.members[1]] = true;
114
+ return true;
115
+ });
116
+ if (groups.length !== loaded.length) shouldRewrite = true;
117
+ if (shouldRewrite) save();
118
+ }
119
+
120
+ function isMultiUser() {
121
+ return !!(usersModule && usersModule.isMultiUser && usersModule.isMultiUser());
122
+ }
123
+
124
+ function listFor(ws) {
125
+ if (!isMultiUser()) return groups.slice();
126
+ if (!ws || !ws._clayUser) return [];
127
+ return groups.filter(function (group) { return group.ownerId === ws._clayUser.id; });
128
+ }
129
+
130
+ function groupForMember(localId) {
131
+ for (var i = 0; i < groups.length; i++) {
132
+ if (groups[i].members.indexOf(localId) !== -1) return groups[i];
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function canAccess(ws, session) {
138
+ if (!isMultiUser()) return true;
139
+ return !!(ws && ws._clayUser && usersModule.canAccessSession(
140
+ ws._clayUser.id, session, { visibility: "public" }
141
+ ));
142
+ }
143
+
144
+ function canOwn(ws, group) {
145
+ return !isMultiUser() || !!(ws && ws._clayUser && group.ownerId === ws._clayUser.id);
146
+ }
147
+
148
+ function create(ws, msg) {
149
+ var members = msg && msg.members;
150
+ if (!Array.isArray(members) || members.length !== 2) return { ok: false, error: "A split group requires exactly two sessions" };
151
+ if (!Number.isInteger(members[0]) || !Number.isInteger(members[1]) || members[0] === members[1]) {
152
+ return { ok: false, error: "Split group members must be two distinct session ids" };
153
+ }
154
+ var left = sessions.get(members[0]);
155
+ var right = sessions.get(members[1]);
156
+ if (!left || !right) return { ok: false, error: "Session not found" };
157
+ if (!canAccess(ws, left) || !canAccess(ws, right)) return { ok: false, error: "Session access denied" };
158
+ if (groupForMember(members[0]) || groupForMember(members[1])) return { ok: false, error: "A session can belong to only one split group" };
159
+ var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
160
+ var group = {
161
+ id: "sg_" + Date.now().toString(36) + "_" + crypto.randomBytes(3).toString("hex"),
162
+ name: requestedName || autoGroupName(left.title, right.title),
163
+ nameCustomized: !!requestedName,
164
+ ownerId: isMultiUser() && ws && ws._clayUser ? ws._clayUser.id : null,
165
+ members: members.slice(),
166
+ createdAt: Date.now(),
167
+ };
168
+ if (msg.pair) {
169
+ if (msg.pair.driverId === msg.pair.workerId || members.indexOf(msg.pair.driverId) === -1 || members.indexOf(msg.pair.workerId) === -1) {
170
+ return { ok: false, error: "Pair roles must reference both split group members" };
171
+ }
172
+ group.pair = { driverId: msg.pair.driverId, workerId: msg.pair.workerId };
173
+ }
174
+ groups.push(group);
175
+ save();
176
+ broadcast();
177
+ return { ok: true, group: group };
178
+ }
179
+
180
+ function rename(ws, msg) {
181
+ var group = groups.find(function (item) { return item.id === (msg && msg.id); });
182
+ if (!group) return { ok: false, error: "Split group not found" };
183
+ if (!canOwn(ws, group)) return { ok: false, error: "Only the group owner can rename it" };
184
+ var name = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
185
+ if (!name) return { ok: false, error: "Group name is required" };
186
+ group.name = name;
187
+ group.nameCustomized = true;
188
+ save();
189
+ broadcast();
190
+ return { ok: true, group: group };
191
+ }
192
+
193
+ // Assign or clear Driver/Worker roles on an existing group. driverId null
194
+ // clears the pair (back to ad-hoc: both members get the partner tools).
195
+ // Role changes apply to tool mounting on the next query start; the runtime
196
+ // guard in project-session-pair enforces them immediately for live queries.
197
+ function setPair(ws, msg) {
198
+ var group = groups.find(function (item) { return item.id === (msg && msg.id); });
199
+ if (!group) return { ok: false, error: "Split group not found" };
200
+ if (!canOwn(ws, group)) return { ok: false, error: "Only the group owner can change pair roles" };
201
+ if (msg.driverId == null) {
202
+ delete group.pair;
203
+ delete group.pairCliIds;
204
+ save();
205
+ broadcast();
206
+ return { ok: true, group: group };
207
+ }
208
+ if (!Number.isInteger(msg.driverId) || group.members.indexOf(msg.driverId) === -1) {
209
+ return { ok: false, error: "Driver must be a member of the split group" };
210
+ }
211
+ var workerId = group.members[0] === msg.driverId ? group.members[1] : group.members[0];
212
+ group.pair = { driverId: msg.driverId, workerId: workerId };
213
+ save();
214
+ broadcast();
215
+ return { ok: true, group: group };
216
+ }
217
+
218
+ function dissolve(ws, msg) {
219
+ var index = groups.findIndex(function (item) { return item.id === (msg && msg.id); });
220
+ if (index === -1) return { ok: false, error: "Split group not found" };
221
+ if (!canOwn(ws, groups[index])) return { ok: false, error: "Only the group owner can separate it" };
222
+ var removed = groups.splice(index, 1)[0];
223
+ save();
224
+ broadcast();
225
+ return { ok: true, group: removed };
226
+ }
227
+
228
+ function dissolveBySession(localId) {
229
+ var before = groups.length;
230
+ groups = groups.filter(function (group) { return group.members.indexOf(localId) === -1; });
231
+ if (groups.length === before) return false;
232
+ save();
233
+ broadcast();
234
+ return true;
235
+ }
236
+
237
+ function refreshAutoName(localId) {
238
+ var group = groupForMember(localId);
239
+ if (!group) return false;
240
+ if (group.nameCustomized) {
241
+ // Auto-title fires on a member's first message, which is also when a
242
+ // blank member gains its cliSessionId. Re-save so the durable anchor
243
+ // is captured even when the name itself stays untouched.
244
+ var fresh = currentCliIds(group);
245
+ var prev = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
246
+ if (fresh[0] !== prev[0] || fresh[1] !== prev[1]) save();
247
+ return false;
248
+ }
249
+ group.name = autoGroupName(sessions.get(group.members[0]).title, sessions.get(group.members[1]).title);
250
+ save();
251
+ broadcast();
252
+ return true;
253
+ }
254
+
255
+ load();
256
+ return { create: create, rename: rename, setPair: setPair, dissolve: dissolve, dissolveBySession: dissolveBySession,
257
+ refreshAutoName: refreshAutoName,
258
+ listFor: listFor, groupForMember: groupForMember, get groups() { return groups; } };
259
+ }
260
+
261
+ function attachSplitGroups(ctx) {
262
+ var store = createSplitGroupStore({
263
+ sessions: ctx.sm.sessions,
264
+ sessionsDir: ctx.sm.sessionsDir,
265
+ usersModule: ctx.usersModule,
266
+ broadcast: broadcast,
267
+ });
268
+
269
+ function sendState(ws) {
270
+ if (!ws || ws._clayPane) return;
271
+ ctx.sendTo(ws, { type: "split_groups", groups: store.listFor(ws) });
272
+ }
273
+
274
+ function broadcast() {
275
+ for (var ws of ctx.clients) {
276
+ if (ws.readyState === 1) sendState(ws);
277
+ }
278
+ }
279
+
280
+ function handleMessage(ws, msg) {
281
+ var action = null;
282
+ var result = null;
283
+ if (msg.type === "split_group_create") { action = "create"; result = store.create(ws, msg); }
284
+ else if (msg.type === "split_group_rename") { action = "rename"; result = store.rename(ws, msg); }
285
+ else if (msg.type === "split_group_dissolve") { action = "dissolve"; result = store.dissolve(ws, msg); }
286
+ else if (msg.type === "split_group_set_pair") { action = "set_pair"; result = store.setPair(ws, msg); }
287
+ else return false;
288
+ ctx.sendTo(ws, { type: "split_group_result", action: action, ok: result.ok, error: result.error || null, group: result.group || null });
289
+ return true;
290
+ }
291
+
292
+ ctx.sm.setOnSessionDeleted(store.dissolveBySession);
293
+ ctx.sm.setOnSessionRenamed(store.refreshAutoName);
294
+ return { handleMessage: handleMessage, sendConnectionState: sendState, store: store };
295
+ }
296
+
297
+ module.exports = { attachSplitGroups: attachSplitGroups, createSplitGroupStore: createSplitGroupStore,
298
+ autoGroupName: autoGroupName };
package/lib/sessions.js CHANGED
@@ -4,6 +4,7 @@ var config = require("./config");
4
4
  var utils = require("./utils");
5
5
  var users = require("./users");
6
6
  var { CODEX_DEFAULTS } = require("./codex-defaults");
7
+ var hygiene = require("./session-hygiene");
7
8
 
8
9
  function createSessionManager(opts) {
9
10
  var cwd = opts.cwd;
@@ -12,6 +13,8 @@ function createSessionManager(opts) {
12
13
  var sendEach = opts.sendEach || null; // function(fn) - call fn(ws) for each connected client
13
14
  var sendAndRecord = null; // set after init via setSendAndRecord
14
15
  var onSessionDone = opts.onSessionDone || function () {};
16
+ var onSessionDeleted = opts.onSessionDeleted || function () {};
17
+ var onSessionRenamed = opts.onSessionRenamed || function () {};
15
18
 
16
19
  // --- Multi-session state ---
17
20
  var nextLocalId = 1;
@@ -29,10 +32,62 @@ function createSessionManager(opts) {
29
32
  var codexWebSearch = CODEX_DEFAULTS.webSearch;
30
33
 
31
34
  // --- Session persistence (centralized in ~/.clay/sessions/{encoded-cwd}/) ---
32
- var sessionsBase = path.join(config.CONFIG_DIR, "sessions");
35
+ var sessionsBase = opts.sessionsBase || path.join(config.CONFIG_DIR, "sessions");
33
36
  var encodedCwd = utils.resolveEncodedDir(sessionsBase, cwd);
34
37
  var sessionsDir = path.join(sessionsBase, encodedCwd);
35
38
  fs.mkdirSync(sessionsDir, { recursive: true });
39
+ var cliSessionsDir = opts.cliSessionsDir || path.join(config.REAL_HOME, ".claude", "projects", utils.encodeCwd(cwd));
40
+ var deletedCliSessionsPath = path.join(sessionsDir, "deleted-cli-sessions.json");
41
+ var deletedCliSessionIds = new Set();
42
+ try {
43
+ var deletedCliData = JSON.parse(fs.readFileSync(deletedCliSessionsPath, "utf8"));
44
+ var deletedCliIds = Array.isArray(deletedCliData) ? deletedCliData : deletedCliData.sessionIds;
45
+ if (Array.isArray(deletedCliIds)) {
46
+ for (var dci = 0; dci < deletedCliIds.length; dci++) {
47
+ if (typeof deletedCliIds[dci] === "string") deletedCliSessionIds.add(deletedCliIds[dci]);
48
+ }
49
+ }
50
+ } catch (e) {}
51
+
52
+ function rememberDeletedCliSession(cliSessionId) {
53
+ if (!cliSessionId || deletedCliSessionIds.has(cliSessionId)) return;
54
+ deletedCliSessionIds.add(cliSessionId);
55
+ try {
56
+ var data = JSON.stringify({ version: 1, sessionIds: Array.from(deletedCliSessionIds) }, null, 2) + "\n";
57
+ var tmpPath = deletedCliSessionsPath + ".tmp." + process.pid;
58
+ fs.writeFileSync(tmpPath, data);
59
+ if (process.platform !== "win32") {
60
+ try { fs.chmodSync(tmpPath, 0o600); } catch (chmodErr) {}
61
+ }
62
+ fs.renameSync(tmpPath, deletedCliSessionsPath);
63
+ } catch (e) {
64
+ console.error("[session] Failed to remember deleted CLI session:", e.message);
65
+ }
66
+ }
67
+
68
+ function readCliTranscriptHead(filePath) {
69
+ var MAX_READ = 64 * 1024;
70
+ try {
71
+ var stat = fs.statSync(filePath);
72
+ var fd = fs.openSync(filePath, "r");
73
+ var bytesToRead = Math.min(stat.size, MAX_READ);
74
+ var buf = Buffer.alloc(bytesToRead);
75
+ fs.readSync(fd, buf, 0, bytesToRead, 0);
76
+ fs.closeSync(fd);
77
+ var lines = buf.toString("utf8").split("\n");
78
+ var events = [];
79
+ for (var i = 0; i < lines.length; i++) {
80
+ if (!lines[i]) continue;
81
+ try {
82
+ var event = JSON.parse(lines[i]);
83
+ if (event && typeof event === "object") events.push(event);
84
+ } catch (e) {}
85
+ }
86
+ return events;
87
+ } catch (e) {
88
+ return null;
89
+ }
90
+ }
36
91
 
37
92
  // Auto-migrate sessions from legacy locations:
38
93
  // v1: {cwd}/.claude-relay/sessions/
@@ -94,15 +149,20 @@ function createSessionManager(opts) {
94
149
  };
95
150
  if (session.ownerId) metaObj.ownerId = session.ownerId;
96
151
  if (session.vendor) metaObj.vendor = session.vendor;
152
+ if (session.model) metaObj.model = session.model;
153
+ if (session.effort) metaObj.effort = session.effort;
97
154
  // Persist the session's "born" mode so TUI sessions reappear after a
98
155
  // daemon restart. terminalId/runtimeMode/runtimeTerminalId are
99
156
  // transient (PTY ids don't survive restart), so they aren't stored;
100
157
  // the click handler will respawn the PTY via `claude --resume` when
101
158
  // the user reopens the session.
102
159
  if (session.mode === "tui") metaObj.mode = "tui";
160
+ if (session.adopted) metaObj.adopted = true;
103
161
  // Born-TUI sessions launched in bypass-permissions mode persist the flag
104
162
  // so lazy-resume (`claude --resume`) re-spawns with the same flag.
105
163
  if (session.dangerouslySkipPermissions) metaObj.dangerouslySkipPermissions = true;
164
+ if (session.permissionMode) metaObj.permissionMode = session.permissionMode;
165
+ if (session.permissionModeBeforeFullAccess) metaObj.permissionModeBeforeFullAccess = session.permissionModeBeforeFullAccess;
106
166
  if (session.sessionVisibility) metaObj.sessionVisibility = session.sessionVisibility;
107
167
  if (session.bookmarked) metaObj.bookmarked = true;
108
168
  if (typeof session.favoriteOrder === "number") metaObj.favoriteOrder = session.favoriteOrder;
@@ -144,6 +204,16 @@ function createSessionManager(opts) {
144
204
  }
145
205
  }
146
206
 
207
+ function deriveSessionModel(history) {
208
+ for (var i = history.length - 1; i >= 0; i--) {
209
+ var modelUsage = history[i] && history[i].modelUsage;
210
+ if (!modelUsage || typeof modelUsage !== "object") continue;
211
+ var models = Object.keys(modelUsage);
212
+ if (models.length > 0) return models[0];
213
+ }
214
+ return null;
215
+ }
216
+
147
217
  function loadSessions() {
148
218
  var files;
149
219
  try { files = fs.readdirSync(sessionsDir); } catch { return; }
@@ -207,6 +277,8 @@ function createSessionManager(opts) {
207
277
  lastRewindUuid: m.lastRewindUuid || null,
208
278
  };
209
279
  if (m.vendor) session.vendor = m.vendor;
280
+ session.model = m.model || deriveSessionModel(loaded[i].history);
281
+ session.effort = m.effort || null;
210
282
  if (m.loop) session.loop = m.loop;
211
283
  if (m.spawn) session.spawn = m.spawn;
212
284
  if (m.debateState) session.debateState = m.debateState;
@@ -217,7 +289,13 @@ function createSessionManager(opts) {
217
289
  // here so it shows up in the sidebar with the right icon; the
218
290
  // switch_session handler respawns the PTY on click.
219
291
  session.mode = (m.mode === "tui") ? "tui" : "gui";
220
- session.dangerouslySkipPermissions = !!m.dangerouslySkipPermissions;
292
+ if (m.adopted) session.adopted = true;
293
+ // The dangerous CLI flag belongs to born-TUI sessions only. Migrate any
294
+ // GUI metadata written by the short-lived full-access implementation to
295
+ // Clay's own per-session permission mode.
296
+ session.dangerouslySkipPermissions = session.mode === "tui" && !!m.dangerouslySkipPermissions;
297
+ session.permissionMode = m.permissionMode || (session.mode === "gui" && m.dangerouslySkipPermissions ? "bypassPermissions" : null);
298
+ session.permissionModeBeforeFullAccess = m.permissionModeBeforeFullAccess || null;
221
299
  session.terminalId = null;
222
300
  session.runtimeMode = null;
223
301
  session.runtimeTerminalId = null;
@@ -243,13 +321,11 @@ function createSessionManager(opts) {
243
321
  // Strict skip rules:
244
322
  // - cliSessionId already known to Clay (avoids duplicate records)
245
323
  // - File has zero user messages (incomplete / corrupted file)
246
- // - Warmup shape: 1 user message that is literal "hi", 0 assistant
247
- // messages (covered by daemon cleanup but defensive here too)
324
+ // - Warmup shape: 1 real "hi" prompt, optional automatic interruption,
325
+ // and 0 assistant messages (covered by daemon cleanup but defensive)
248
326
  function adoptOrphanedCliSessions() {
249
- var encodedCwd = utils.encodeCwd(cwd);
250
- var cliDir = path.join(config.REAL_HOME, ".claude", "projects", encodedCwd);
251
327
  var files;
252
- try { files = fs.readdirSync(cliDir); } catch (e) { return; }
328
+ try { files = fs.readdirSync(cliSessionsDir); } catch (e) { return; }
253
329
 
254
330
  // Build set of cliSessionIds Clay already tracks
255
331
  var knownCliIds = new Set();
@@ -257,37 +333,22 @@ function createSessionManager(opts) {
257
333
  if (s.cliSessionId) knownCliIds.add(s.cliSessionId);
258
334
  });
259
335
 
260
- var MAX_READ = 64 * 1024; // first 64KB is enough for first user message
261
336
  var adopted = 0;
262
337
 
263
338
  for (var i = 0; i < files.length; i++) {
264
339
  if (!files[i].endsWith(".jsonl")) continue;
265
340
  var cliSid = files[i].slice(0, -".jsonl".length);
266
341
  if (knownCliIds.has(cliSid)) continue;
342
+ if (deletedCliSessionIds.has(cliSid)) continue;
267
343
 
268
- var fp = path.join(cliDir, files[i]);
269
- var raw;
270
- try {
271
- var stat = fs.statSync(fp);
272
- var fd = fs.openSync(fp, "r");
273
- var bytesToRead = Math.min(stat.size, MAX_READ);
274
- var buf = Buffer.alloc(bytesToRead);
275
- fs.readSync(fd, buf, 0, bytesToRead, 0);
276
- fs.closeSync(fd);
277
- raw = buf.toString("utf8");
278
- } catch (e) { continue; }
279
-
280
- var lines = raw.split("\n");
344
+ var fp = path.join(cliSessionsDir, files[i]);
345
+ var events = readCliTranscriptHead(fp);
346
+ if (!events) continue;
281
347
  var userCount = 0;
282
- var assistantCount = 0;
283
348
  var firstUserText = null;
284
349
  var createdAtIso = null;
285
- for (var li = 0; li < lines.length; li++) {
286
- var line = lines[li];
287
- if (!line) continue;
288
- var ev;
289
- try { ev = JSON.parse(line); } catch (e) { continue; }
290
- if (!ev || typeof ev !== "object") continue;
350
+ for (var li = 0; li < events.length; li++) {
351
+ var ev = events[li];
291
352
  if (ev.type === "user" && ev.message && ev.message.role === "user") {
292
353
  userCount++;
293
354
  if (firstUserText == null) {
@@ -303,14 +364,12 @@ function createSessionManager(opts) {
303
364
  }
304
365
  if (ev.timestamp && !createdAtIso) createdAtIso = ev.timestamp;
305
366
  }
306
- } else if (ev.type === "assistant") {
307
- assistantCount++;
308
367
  }
309
368
  }
310
369
 
311
370
  if (userCount === 0) continue;
312
371
  // Defensive warmup skip (daemon also cleans these)
313
- if (userCount === 1 && assistantCount === 0 && firstUserText === "hi") continue;
372
+ if (hygiene.isClaudeWarmupTranscript(events)) continue;
314
373
 
315
374
  // Title: first user message trimmed/truncated. Fall back to a
316
375
  // neutral label so the sidebar entry is identifiable.
@@ -345,6 +404,10 @@ function createSessionManager(opts) {
345
404
  lastRewindUuid: null,
346
405
  vendor: "claude",
347
406
  mode: "tui",
407
+ // Adopted from an external `claude` run in this cwd, not started by
408
+ // Clay. Notification emitters must stay silent for these until the
409
+ // user actually opens them in Clay (a PTY gets attached).
410
+ adopted: true,
348
411
  terminalId: null,
349
412
  runtimeMode: null,
350
413
  runtimeTerminalId: null,
@@ -363,9 +426,45 @@ function createSessionManager(opts) {
363
426
  }
364
427
  }
365
428
 
429
+ // Remove rows created by older Clay versions that misclassified an SDK
430
+ // warmup transcript as a real external session. Deletion is tombstoned, so
431
+ // the preserved Claude file cannot be adopted again on the next restart.
432
+ function sweepAdoptedWarmupSessions() {
433
+ var stale = [];
434
+ sessions.forEach(function (session) {
435
+ if (!session.adopted || !session.cliSessionId) return;
436
+ var transcriptPath = path.join(cliSessionsDir, session.cliSessionId + ".jsonl");
437
+ var events = readCliTranscriptHead(transcriptPath);
438
+ var missingEmptySource = !fs.existsSync(transcriptPath) && (!session.history || session.history.length === 0);
439
+ if (missingEmptySource || (events && hygiene.isClaudeWarmupTranscript(events))) stale.push(session.localId);
440
+ });
441
+ for (var i = 0; i < stale.length; i++) deleteSessionQuiet(stale[i]);
442
+ if (stale.length > 0) {
443
+ console.log("[sessions] Removed " + stale.length + " adopted SDK warmup session(s) for " + cwd);
444
+ }
445
+ }
446
+
447
+ // Delete blank sessions (never used, nothing attached) that have sat
448
+ // untouched past the grace period. Runs at startup and on new_session.
449
+ function sweepBlankSessions() {
450
+ var stale = hygiene.collectStaleBlankSessions(sessions, activeSessionId, Date.now());
451
+ for (var i = 0; i < stale.length; i++) deleteSessionQuiet(stale[i]);
452
+ if (stale.length > 0) {
453
+ console.log("[sessions] Swept " + stale.length + " stale blank session(s) for " + cwd);
454
+ broadcastSessionList();
455
+ }
456
+ return stale.length;
457
+ }
458
+
459
+ function findReusableBlankSession(opts) {
460
+ return hygiene.findReusableBlankSession(sessions, opts);
461
+ }
462
+
366
463
  // Load persisted sessions from disk, then adopt any orphan CLI sessions
367
464
  loadSessions();
465
+ sweepAdoptedWarmupSessions();
368
466
  adoptOrphanedCliSessions();
467
+ sweepBlankSessions();
369
468
 
370
469
  function getActiveSession() {
371
470
  return sessions.get(activeSessionId) || null;
@@ -403,10 +502,14 @@ function createSessionManager(opts) {
403
502
  favoriteOrder: typeof s.favoriteOrder === "number" ? s.favoriteOrder : null,
404
503
  unread: unreadMap[s.localId] || 0,
405
504
  vendor: s.vendor || null,
505
+ model: s.model || null,
506
+ effort: s.effort || null,
406
507
  mode: s.mode || "gui",
407
508
  terminalId: typeof s.terminalId === "number" ? s.terminalId : null,
408
509
  runtimeMode: s.runtimeMode || null,
409
510
  runtimeTerminalId: typeof s.runtimeTerminalId === "number" ? s.runtimeTerminalId : null,
511
+ dangerouslySkipPermissions: !!s.dangerouslySkipPermissions,
512
+ permissionMode: s.permissionMode || null,
410
513
  };
411
514
  }
412
515
 
@@ -469,7 +572,12 @@ function createSessionManager(opts) {
469
572
  bookmarked: false,
470
573
  favoriteOrder: null,
471
574
  vendor: (sessionOpts && sessionOpts.vendor) || null,
575
+ model: (sessionOpts && sessionOpts.model) || null,
576
+ effort: (sessionOpts && sessionOpts.effort) || null,
472
577
  mode: (sessionOpts && sessionOpts.mode === "tui") ? "tui" : "gui",
578
+ dangerouslySkipPermissions: !!(sessionOpts && sessionOpts.mode === "tui" && sessionOpts.dangerouslySkipPermissions),
579
+ permissionMode: (sessionOpts && sessionOpts.permissionMode) || null,
580
+ permissionModeBeforeFullAccess: null,
473
581
  terminalId: null,
474
582
  };
475
583
  sessions.set(localId, session);
@@ -503,8 +611,12 @@ function createSessionManager(opts) {
503
611
  bookmarked: false,
504
612
  favoriteOrder: null,
505
613
  vendor: (sessionOpts && sessionOpts.vendor) || null,
614
+ model: (sessionOpts && sessionOpts.model) || null,
615
+ effort: (sessionOpts && sessionOpts.effort) || null,
506
616
  mode: (sessionOpts && sessionOpts.mode === "tui") ? "tui" : "gui",
507
- dangerouslySkipPermissions: !!(sessionOpts && sessionOpts.dangerouslySkipPermissions),
617
+ dangerouslySkipPermissions: !!(sessionOpts && sessionOpts.mode === "tui" && sessionOpts.dangerouslySkipPermissions),
618
+ permissionMode: (sessionOpts && sessionOpts.permissionMode) || null,
619
+ permissionModeBeforeFullAccess: null,
508
620
  terminalId: null,
509
621
  };
510
622
  sessions.set(localId, session);
@@ -569,7 +681,11 @@ function createSessionManager(opts) {
569
681
  var session = sessions.get(localId);
570
682
  if (!session) return;
571
683
 
572
- activeSessionId = localId;
684
+ // Pane sockets (split-view iframes) pin their session via switch_session
685
+ // but must not move the project-global active pointer: reloads and
686
+ // session_list "active" flags would otherwise follow whichever pane
687
+ // pinned last instead of what the user is actually looking at.
688
+ if (!(targetWs && targetWs._clayPane)) activeSessionId = localId;
573
689
  if (targetWs) {
574
690
  targetWs._clayActiveSession = localId;
575
691
  // Clear unread for this session (multi-user)
@@ -589,7 +705,7 @@ function createSessionManager(opts) {
589
705
  var _capsByVendor = capabilitiesByVendor || {};
590
706
  var _sessionVendor = session.vendor || defaultVendor || "claude";
591
707
  var _vendorCaps = _capsByVendor[_sessionVendor] || {};
592
- _send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended });
708
+ _send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, model: session.model || null, effort: session.effort || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended, dangerouslySkipPermissions: !!session.dangerouslySkipPermissions, permissionMode: session.permissionMode || null });
593
709
  // Send vendor-specific slash commands
594
710
  var _vendorCmds = slashCommandsByVendor[_sessionVendor] || slashCommands || [];
595
711
  _send({ type: "slash_commands", commands: _vendorCmds, vendor: _sessionVendor });
@@ -656,10 +772,14 @@ function createSessionManager(opts) {
656
772
  }
657
773
 
658
774
  if (session.cliSessionId) {
775
+ rememberDeletedCliSession(session.cliSessionId);
659
776
  try { fs.unlinkSync(sessionFilePath(session.cliSessionId)); } catch(e) {}
660
777
  }
661
778
 
662
779
  sessions.delete(localId);
780
+ try { onSessionDeleted(localId); } catch (e) {
781
+ console.error("[session] Session deletion hook failed:", e.message || e);
782
+ }
663
783
 
664
784
  if (activeSessionId === localId) {
665
785
  var remaining = [...sessions.keys()];
@@ -694,9 +814,13 @@ function createSessionManager(opts) {
694
814
  session.worker = null;
695
815
  }
696
816
  if (session.cliSessionId) {
817
+ rememberDeletedCliSession(session.cliSessionId);
697
818
  try { fs.unlinkSync(sessionFilePath(session.cliSessionId)); } catch(e) {}
698
819
  }
699
820
  sessions.delete(localId);
821
+ try { onSessionDeleted(localId); } catch (e) {
822
+ console.error("[session] Session deletion hook failed:", e.message || e);
823
+ }
700
824
  }
701
825
 
702
826
  function deleteSessionsBulk(localIds, targetWs) {
@@ -1054,6 +1178,9 @@ function createSessionManager(opts) {
1054
1178
  set codexWebSearch(v) { codexWebSearch = v; },
1055
1179
  sessions: sessions,
1056
1180
  sessionsDir: sessionsDir,
1181
+ setOnSessionDeleted: function (fn) { onSessionDeleted = fn || function () {}; },
1182
+ setOnSessionRenamed: function (fn) { onSessionRenamed = fn || function () {}; },
1183
+ notifySessionRenamed: function (localId) { onSessionRenamed(localId); },
1057
1184
  HISTORY_PAGE_SIZE: HISTORY_PAGE_SIZE,
1058
1185
  getActiveSession: getActiveSession,
1059
1186
  createSession: createSession,
@@ -1062,6 +1189,8 @@ function createSessionManager(opts) {
1062
1189
  deleteSession: deleteSession,
1063
1190
  deleteSessionQuiet: deleteSessionQuiet,
1064
1191
  deleteSessionsBulk: deleteSessionsBulk,
1192
+ sweepBlankSessions: sweepBlankSessions,
1193
+ findReusableBlankSession: findReusableBlankSession,
1065
1194
  resumeSession: resumeSession,
1066
1195
  broadcastSessionList: broadcastSessionList,
1067
1196
  getTotalUnread: function (ws) {