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.
- package/lib/daemon.js +6 -27
- package/lib/project-connection.js +21 -8
- package/lib/project-session-pair.js +312 -0
- package/lib/project-session-spawn.js +8 -4
- package/lib/project-sessions.js +108 -10
- package/lib/project-user-message.js +18 -8
- package/lib/project.js +33 -0
- package/lib/public/app.js +20 -0
- package/lib/public/css/menus.css +83 -1
- package/lib/public/css/messages.css +14 -0
- package/lib/public/css/pane.css +420 -0
- package/lib/public/css/sidebar.css +40 -0
- package/lib/public/css/sticky-notes.css +2 -0
- package/lib/public/index.html +4 -0
- package/lib/public/modules/app-connection.js +6 -0
- package/lib/public/modules/app-favicon.js +5 -0
- package/lib/public/modules/app-header.js +101 -6
- package/lib/public/modules/app-messages.js +59 -6
- package/lib/public/modules/app-panels.js +49 -27
- package/lib/public/modules/app-rendering.js +11 -4
- package/lib/public/modules/pane-bridge.js +41 -0
- package/lib/public/modules/pane-session.js +18 -0
- package/lib/public/modules/sidebar-sessions.js +175 -1
- package/lib/public/modules/sidebar.js +2 -0
- package/lib/public/modules/split-group-helpers.js +18 -0
- package/lib/public/modules/split-pair-ui.js +324 -0
- package/lib/public/modules/split-view.js +499 -0
- package/lib/public/modules/sticky-notes.js +32 -91
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +111 -20
- package/lib/sdk-message-processor.js +2 -2
- package/lib/server.js +24 -4
- package/lib/session-hygiene.js +100 -0
- package/lib/session-pair-mcp-server.js +28 -0
- package/lib/session-split-groups.js +298 -0
- package/lib/sessions.js +163 -34
- package/lib/ws-request.js +14 -0
- package/lib/ws-schema.js +13 -0
- package/lib/yoke/adapters/claude.js +32 -0
- package/lib/yoke/adapters/codex.js +100 -77
- package/lib/yoke/adapters/kiro.js +34 -35
- package/lib/yoke/codex-app-server.js +56 -6
- package/lib/yoke/index.js +1 -0
- package/lib/yoke/skill-discovery.js +196 -0
- package/lib/yoke/vendor-registry.js +34 -0
- 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.
|
|
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
|
|
247
|
-
// messages (covered by daemon cleanup but defensive
|
|
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(
|
|
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(
|
|
269
|
-
var
|
|
270
|
-
|
|
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 <
|
|
286
|
-
var
|
|
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 (
|
|
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
|
-
|
|
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) {
|