mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
feat(anthropic): import Claude Desktop custom groups (#111644)
* feat(anthropic): import Claude Desktop custom groups * fix(anthropic): resolve Claude Desktop groups from LevelDB entries Group discovery regexed whole decompressed blocks, so matches were not attributable to a Local Storage value and byte order decided the winner. On a real 5.4MB store that mislabelled 59 of 159 sessions, surfacing a mojibake label spliced out of Snappy copy-record bytes. Parse SSTable entries properly instead: prefix-delta keys bounded by the restart array, newest internal sequence per user key (honoring deletions), and record scanning confined to a single value. Values are normalized so Chromium's UTF-16 form scans like Latin-1, and unflushed WAL writes keep precedence over SSTables. Prod LOC grows ~77; it buys structural correctness in place of ordering luck, and folds the old index-only walk into one shared entry decoder. Verified against the live store: 159 assignments, 0 mislabelled versus an independent entry-level ground truth, 0 control-character labels. * fix(ui): sort custom session groups ahead of project groups Custom groups were pushed into the section list as encountered, so their position depended on roster order rather than the documented behavior; the existing test only passed because its fixture happened to be ordered that way. Collect custom and project groups separately and concatenate, and assert the guarantee with a reversed-input case. * fix(anthropic): widen LevelDB fixture key type for test typecheck The prefix-delta helper assigned a Buffer into a Buffer-typed accumulator whose generic argument differed, which tsgo rejects in the test lane. * chore(anthropic): drop release-owned changelog edit from the PR CHANGELOG.md is generated at release time, so a normal PR must not carry an entry. The release-note context lives in the feature commit message and the PR body instead.
This commit is contained in:
@@ -3882,6 +3882,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
public let modelprovider: String?
|
||||
public let cliversion: String?
|
||||
public let gitbranch: String?
|
||||
public let customgroup: String?
|
||||
public let archived: Bool
|
||||
public let sessionkey: String?
|
||||
public let cancontinue: Bool
|
||||
@@ -3900,6 +3901,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
modelprovider: String? = nil,
|
||||
cliversion: String? = nil,
|
||||
gitbranch: String? = nil,
|
||||
customgroup: String? = nil,
|
||||
archived: Bool,
|
||||
sessionkey: String? = nil,
|
||||
cancontinue: Bool,
|
||||
@@ -3917,6 +3919,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
self.modelprovider = modelprovider
|
||||
self.cliversion = cliversion
|
||||
self.gitbranch = gitbranch
|
||||
self.customgroup = customgroup
|
||||
self.archived = archived
|
||||
self.sessionkey = sessionkey
|
||||
self.cancontinue = cancontinue
|
||||
@@ -3936,6 +3939,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
case modelprovider = "modelProvider"
|
||||
case cliversion = "cliVersion"
|
||||
case gitbranch = "gitBranch"
|
||||
case customgroup = "customGroup"
|
||||
case archived
|
||||
case sessionkey = "sessionKey"
|
||||
case cancontinue = "canContinue"
|
||||
|
||||
@@ -223,7 +223,7 @@ select it to open the owning Approvals page.
|
||||
- Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`).
|
||||
- Channel probe refreshes keep the previous snapshot visible while slow provider checks finish, and label partial snapshots when a probe or audit exceeds its UI budget.
|
||||
- Threads (a workspace page at `/sessions`, with a **Worktrees** tab alongside it): list configured-agent sessions by default, pin frequent sessions, rename them, archive or restore inactive sessions, fall back from stale unconfigured agent session keys, and apply per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`). Pinned sessions sort above recent unpinned sessions; archived sessions live in the Threads page's archived view and keep their transcripts. Rows show an unread dot for sessions with activity since their last read, with mark-unread/mark-read actions (`sessions.patch { unread }`), and a Fork action that branches the transcript into a new session (`sessions.create { parentSessionKey, fork: true }`). Overview tiles above the table summarize the loaded roster (session count, live runs, unread sessions, total tokens), each row carries a kind glyph with a live-run dot, status renders as a plain dot plus label, and the Tokens column shows a context-window usage meter when the session reports token and context sizes. Row management actions live in a per-row menu (kebab button or right-click) mirroring the sidebar's session menu, and the row drawer carries the agent runtime and run duration alongside the other session details.
|
||||
- Native Claude and Codex sidebar catalogs stream one host at a time, then reconcile after node connectivity changes, on page focus, and at most every 30 seconds while visible. Catalog changes trigger a faster follow-up pass, so sessions created in the native tools appear without reloading the Control UI.
|
||||
- Native Claude and Codex sidebar catalogs stream one host at a time, then reconcile after node connectivity changes, on page focus, and at most every 30 seconds while visible. Catalog changes trigger a faster follow-up pass, so sessions created in the native tools appear without reloading the Control UI. Claude Desktop rows also retain their local custom-group label when present; OpenClaw reads that mapping from Desktop's local store and never writes it.
|
||||
- Session grouping: a Group by control organizes the sessions table into sections by custom groups, channel, kind, agent, or date. Custom groups persist per session via `sessions.patch` (`category`), so sessions started from message channels (Discord, Telegram, WhatsApp, ...) can be categorized too; assign groups by dragging rows onto a section, or with the per-row group selector, and create groups with the New group action.
|
||||
- Memory (a tab on the Agents page, scoped to the selected agent): dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`).
|
||||
- Import Memory (`/memory-import`, reached from the Agents page's Memory tab): preview and copy local Claude Code auto-memory, Codex consolidated memory, or Hermes memory files into the selected agent workspace (`migrations.memory.plan`, `migrations.memory.apply`).
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const LEVELDB_FOOTER_BYTES = 48;
|
||||
const LEVELDB_BLOCK_TRAILER_BYTES = 5;
|
||||
const MAX_LEVELDB_FILE_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_LEVELDB_FILES = 256;
|
||||
const MAX_LEVELDB_TOTAL_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_SNAPPY_BLOCK_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
type ParsedGroups = {
|
||||
groups: Map<string, string>;
|
||||
assignments: Map<string, string>;
|
||||
};
|
||||
|
||||
type LevelDbValue = {
|
||||
sequence: bigint;
|
||||
value: Uint8Array;
|
||||
};
|
||||
|
||||
// Desktop spreads group state across more than one Local Storage key, and the key
|
||||
// names are private to it. Select by record shape instead of key name so a rename
|
||||
// degrades to "no groups" rather than silently wrong ones, and so only matching
|
||||
// values are retained in memory. Accepted tradeoff: a deletion tombstone carries no
|
||||
// records, so clearing Desktop's site data can leave stale labels until compaction.
|
||||
const GROUP_RECORD_MARKER = /"code:local_[a-f0-9-]+":"cg-|"id":"cg-[a-f0-9-]+","name":"/i;
|
||||
|
||||
const LEVELDB_VALUE_KIND = 1;
|
||||
const EMPTY_VALUE = new Uint8Array();
|
||||
|
||||
/**
|
||||
* Chromium stores a Local Storage value as UTF-16 whenever it holds a character that
|
||||
* Latin-1 cannot represent, so ASCII JSON arrives with interleaved NUL bytes. Dropping
|
||||
* them yields one scannable form for both encodings; without this a single emoji in any
|
||||
* group name hides every record in that value.
|
||||
*/
|
||||
function localStorageText(value: Uint8Array): string {
|
||||
return Buffer.from(value).toString("latin1").replaceAll("\0", "");
|
||||
}
|
||||
|
||||
function readVarint(bytes: Uint8Array, offset: number): [number, number] {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
let cursor = offset;
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const byte = bytes[cursor];
|
||||
if (byte === undefined) {
|
||||
throw new Error("unexpected end of LevelDB varint");
|
||||
}
|
||||
cursor += 1;
|
||||
value += (byte & 0x7f) * 2 ** shift;
|
||||
if ((byte & 0x80) === 0) {
|
||||
return [value, cursor];
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
throw new Error("invalid LevelDB varint");
|
||||
}
|
||||
|
||||
function decodeSnappy(compressed: Uint8Array): Uint8Array {
|
||||
let offset = 0;
|
||||
const [expectedLength, afterLength] = readVarint(compressed, offset);
|
||||
offset = afterLength;
|
||||
if (expectedLength > MAX_SNAPPY_BLOCK_BYTES) {
|
||||
throw new Error("Claude Desktop LevelDB block is too large");
|
||||
}
|
||||
const output = new Uint8Array(expectedLength);
|
||||
let written = 0;
|
||||
while (offset < compressed.length) {
|
||||
const tag = compressed[offset];
|
||||
if (tag === undefined) {
|
||||
throw new Error("unexpected end of Snappy block");
|
||||
}
|
||||
offset += 1;
|
||||
const kind = tag & 0x03;
|
||||
if (kind === 0) {
|
||||
let length = tag >>> 2;
|
||||
if (length < 60) {
|
||||
length += 1;
|
||||
} else {
|
||||
const count = length - 59;
|
||||
if (offset + count > compressed.length) {
|
||||
throw new Error("invalid Snappy literal length");
|
||||
}
|
||||
length = 1;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
length += (compressed[offset + index] ?? 0) * 2 ** (8 * index);
|
||||
}
|
||||
offset += count;
|
||||
}
|
||||
if (offset + length > compressed.length || written + length > output.length) {
|
||||
throw new Error("invalid Snappy literal");
|
||||
}
|
||||
output.set(compressed.subarray(offset, offset + length), written);
|
||||
offset += length;
|
||||
written += length;
|
||||
continue;
|
||||
}
|
||||
let length: number;
|
||||
let copyOffset: number;
|
||||
if (kind === 1) {
|
||||
length = ((tag >>> 2) & 0x07) + 4;
|
||||
copyOffset = ((tag >>> 5) << 8) | (compressed[offset] ?? 0);
|
||||
offset += 1;
|
||||
} else if (kind === 2) {
|
||||
length = (tag >>> 2) + 1;
|
||||
copyOffset = (compressed[offset] ?? 0) | ((compressed[offset + 1] ?? 0) << 8);
|
||||
offset += 2;
|
||||
} else {
|
||||
length = (tag >>> 2) + 1;
|
||||
copyOffset =
|
||||
(compressed[offset] ?? 0) |
|
||||
((compressed[offset + 1] ?? 0) << 8) |
|
||||
((compressed[offset + 2] ?? 0) << 16) |
|
||||
((compressed[offset + 3] ?? 0) << 24);
|
||||
offset += 4;
|
||||
}
|
||||
if (copyOffset <= 0 || copyOffset > written || written + length > output.length) {
|
||||
throw new Error("invalid Snappy copy offset");
|
||||
}
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
output[written] = output[written - copyOffset] ?? 0;
|
||||
written += 1;
|
||||
}
|
||||
}
|
||||
if (written !== output.length) {
|
||||
throw new Error("incomplete Snappy block");
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function readBlock(file: Uint8Array, offset: number, size: number): Uint8Array {
|
||||
const trailerOffset = offset + size;
|
||||
if (offset < 0 || size < 0 || trailerOffset + LEVELDB_BLOCK_TRAILER_BYTES > file.length) {
|
||||
throw new Error("invalid LevelDB block handle");
|
||||
}
|
||||
const block = file.subarray(offset, trailerOffset);
|
||||
switch (file[trailerOffset]) {
|
||||
case 0:
|
||||
return block;
|
||||
case 1:
|
||||
return decodeSnappy(block);
|
||||
default:
|
||||
throw new Error("unsupported LevelDB compression");
|
||||
}
|
||||
}
|
||||
|
||||
function forEachLevelDbEntry(
|
||||
block: Uint8Array,
|
||||
visit: (key: Uint8Array, value: Uint8Array) => void,
|
||||
): void {
|
||||
if (block.length < 4) {
|
||||
throw new Error("invalid LevelDB block");
|
||||
}
|
||||
const restartCount = new DataView(block.buffer, block.byteOffset + block.length - 4, 4).getUint32(
|
||||
0,
|
||||
true,
|
||||
);
|
||||
const entriesEnd = block.length - 4 - restartCount * 4;
|
||||
if (entriesEnd < 0) {
|
||||
throw new Error("invalid LevelDB restart array");
|
||||
}
|
||||
let offset = 0;
|
||||
let previousKey = new Uint8Array();
|
||||
while (offset < entriesEnd) {
|
||||
const [shared, afterShared] = readVarint(block, offset);
|
||||
const [unshared, afterUnshared] = readVarint(block, afterShared);
|
||||
const [valueLength, afterValueLength] = readVarint(block, afterUnshared);
|
||||
const keyEnd = afterValueLength + unshared;
|
||||
const valueEnd = keyEnd + valueLength;
|
||||
if (shared > previousKey.length || valueEnd > entriesEnd) {
|
||||
throw new Error("invalid LevelDB entry");
|
||||
}
|
||||
const key = new Uint8Array(shared + unshared);
|
||||
key.set(previousKey.subarray(0, shared));
|
||||
key.set(block.subarray(afterValueLength, keyEnd), shared);
|
||||
visit(key, block.subarray(keyEnd, valueEnd));
|
||||
previousKey = key;
|
||||
offset = valueEnd;
|
||||
}
|
||||
}
|
||||
|
||||
function levelDbDataBlocks(file: Uint8Array): Uint8Array[] {
|
||||
if (file.length < LEVELDB_FOOTER_BYTES) {
|
||||
return [];
|
||||
}
|
||||
// Footer layout: metaindex handle (offset+size), then index handle (offset+size).
|
||||
const footer = file.subarray(file.length - LEVELDB_FOOTER_BYTES);
|
||||
const [, afterMetaindexOffset] = readVarint(footer, 0);
|
||||
const [, afterMetaindexHandle] = readVarint(footer, afterMetaindexOffset);
|
||||
const [indexOffset, afterIndexOffset] = readVarint(footer, afterMetaindexHandle);
|
||||
const [indexSize] = readVarint(footer, afterIndexOffset);
|
||||
const index = readBlock(file, indexOffset, indexSize);
|
||||
const blocks: Uint8Array[] = [];
|
||||
forEachLevelDbEntry(index, (_key, handle) => {
|
||||
const [blockOffset, afterBlockOffset] = readVarint(handle, 0);
|
||||
const [blockSize] = readVarint(handle, afterBlockOffset);
|
||||
blocks.push(readBlock(file, blockOffset, blockSize));
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function collectLevelDbValues(block: Uint8Array, values: Map<string, LevelDbValue>): void {
|
||||
forEachLevelDbEntry(block, (key, value) => {
|
||||
if (key.length < 8) {
|
||||
throw new Error("invalid LevelDB internal key");
|
||||
}
|
||||
const userKey = Buffer.from(key.subarray(0, -8)).toString("latin1");
|
||||
const kind = key[key.length - 8];
|
||||
let sequence = 0n;
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
sequence |= BigInt(key[key.length - 7 + index] ?? 0) << BigInt(index * 8);
|
||||
}
|
||||
const current = values.get(userKey);
|
||||
if (current && sequence <= current.sequence) {
|
||||
return;
|
||||
}
|
||||
// Record the newest entry for every key even when it holds no group records, so a
|
||||
// deletion or a store whose last group was removed cannot lose to an older value.
|
||||
// Only marker-bearing payloads are retained, which keeps this bounded in memory.
|
||||
const live = kind === LEVELDB_VALUE_KIND && GROUP_RECORD_MARKER.test(localStorageText(value));
|
||||
values.set(userKey, { sequence, value: live ? value : EMPTY_VALUE });
|
||||
});
|
||||
}
|
||||
|
||||
function isPlainGroupName(name: string): boolean {
|
||||
for (let index = 0; index < name.length; index += 1) {
|
||||
const code = name.charCodeAt(index);
|
||||
if (code < 0x20 || code === 0x7f) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function scanGroupRecords(raw: Uint8Array, parsed: ParsedGroups): void {
|
||||
const text = localStorageText(raw);
|
||||
for (const match of text.matchAll(/"id":"(cg-[a-f0-9-]+)","name":"([^"\\]{1,500})"/gi)) {
|
||||
const [, id, name] = match;
|
||||
if (id && name && isPlainGroupName(name) && !parsed.groups.has(id)) {
|
||||
parsed.groups.set(id, name);
|
||||
}
|
||||
}
|
||||
for (const match of text.matchAll(/"code:(local_[a-f0-9-]+)":"(cg-[a-f0-9-]+)"/gi)) {
|
||||
const [, sessionId, groupId] = match;
|
||||
if (sessionId && groupId && !parsed.assignments.has(sessionId)) {
|
||||
parsed.assignments.set(sessionId, groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Desktop stores Code custom groups in Chromium Local Storage, not beside the session JSON.
|
||||
* This reads only labels and local-session assignments; it never mutates Desktop account state.
|
||||
*/
|
||||
export async function readClaudeDesktopCustomGroups(homeDir: string): Promise<Map<string, string>> {
|
||||
const root = path.join(
|
||||
homeDir,
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"Local Storage",
|
||||
"leveldb",
|
||||
);
|
||||
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
||||
const files = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && /\.(ldb|log)$/.test(entry.name))
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(root, entry.name);
|
||||
const stat = await fs.stat(filePath).catch(() => undefined);
|
||||
return stat && stat.size <= MAX_LEVELDB_FILE_BYTES
|
||||
? { filePath, mtimeMs: stat.mtimeMs, size: stat.size }
|
||||
: undefined;
|
||||
}),
|
||||
);
|
||||
const levelDbValues = new Map<string, LevelDbValue>();
|
||||
const logRecords: ParsedGroups = { groups: new Map(), assignments: new Map() };
|
||||
let remainingBytes = MAX_LEVELDB_TOTAL_BYTES;
|
||||
for (const file of files
|
||||
.filter(
|
||||
(candidate): candidate is { filePath: string; mtimeMs: number; size: number } =>
|
||||
candidate !== undefined,
|
||||
)
|
||||
.toSorted(
|
||||
(left, right) => right.mtimeMs - left.mtimeMs || right.filePath.localeCompare(left.filePath),
|
||||
)
|
||||
.slice(0, MAX_LEVELDB_FILES)) {
|
||||
if (file.size > remainingBytes) {
|
||||
continue;
|
||||
}
|
||||
remainingBytes -= file.size;
|
||||
const raw = await fs.readFile(file.filePath).catch(() => undefined);
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
if (!file.filePath.endsWith(".ldb")) {
|
||||
scanGroupRecords(raw, logRecords);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
for (const block of levelDbDataBlocks(raw)) {
|
||||
collectLevelDbValues(block, levelDbValues);
|
||||
}
|
||||
} catch {
|
||||
// Chromium can compact while discovery is reading its local store.
|
||||
}
|
||||
}
|
||||
// The write-ahead log holds writes that have not been flushed into an SSTable yet, so
|
||||
// it seeds the result first and wins on conflict. It is scanned raw rather than replayed,
|
||||
// so its own internal ordering stays best-effort; SSTables then fill in the rest.
|
||||
const parsed: ParsedGroups = {
|
||||
groups: new Map(logRecords.groups),
|
||||
assignments: new Map(logRecords.assignments),
|
||||
};
|
||||
for (const { value } of levelDbValues.values()) {
|
||||
scanGroupRecords(value, parsed);
|
||||
}
|
||||
const assignments = new Map<string, string>();
|
||||
for (const [sessionId, groupId] of parsed.assignments) {
|
||||
const group = parsed.groups.get(groupId);
|
||||
if (group) {
|
||||
assignments.set(sessionId, group);
|
||||
}
|
||||
}
|
||||
return assignments;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type ClaudeSessionCatalogSession = {
|
||||
modelProvider: "anthropic";
|
||||
cliVersion?: string;
|
||||
gitBranch?: string;
|
||||
customGroup?: string;
|
||||
archived: false;
|
||||
};
|
||||
|
||||
|
||||
@@ -138,6 +138,153 @@ async function writeDesktopMetadata(
|
||||
await fs.writeFile(path.join(dir, `local_${name}.json`), JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
function encodeVarint(value: number): Buffer {
|
||||
const bytes: number[] = [];
|
||||
let remaining = value;
|
||||
while (remaining >= 0x80) {
|
||||
bytes.push((remaining & 0x7f) | 0x80);
|
||||
remaining = Math.floor(remaining / 0x80);
|
||||
}
|
||||
bytes.push(remaining);
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function snappyLiteralChunk(value: Buffer): Buffer {
|
||||
if (value.length <= 60) {
|
||||
return Buffer.concat([Buffer.from([(value.length - 1) << 2]), value]);
|
||||
}
|
||||
const length = value.length - 1;
|
||||
const lengthBytes: number[] = [];
|
||||
let remaining = length;
|
||||
while (remaining > 0) {
|
||||
lengthBytes.push(remaining & 0xff);
|
||||
remaining = Math.floor(remaining / 0x100);
|
||||
}
|
||||
return Buffer.concat([Buffer.from([(59 + lengthBytes.length) << 2, ...lengthBytes]), value]);
|
||||
}
|
||||
|
||||
const CLAUDE_GROUP_USER_KEY = Buffer.from("_https://claude.ai\0\x01dframe-store", "latin1");
|
||||
|
||||
function levelDbInternalKey(sequence: number, kind = 1): Buffer {
|
||||
const trailer = Buffer.alloc(8);
|
||||
trailer[0] = kind;
|
||||
let remaining = sequence;
|
||||
for (let index = 1; index < trailer.length; index += 1) {
|
||||
trailer[index] = remaining & 0xff;
|
||||
remaining = Math.floor(remaining / 0x100);
|
||||
}
|
||||
return Buffer.concat([CLAUDE_GROUP_USER_KEY, trailer]);
|
||||
}
|
||||
|
||||
function levelDbDataBlock(
|
||||
entries: Array<{ sequence: number; value: string | Buffer; kind?: number }>,
|
||||
): Buffer {
|
||||
const encoded: Buffer[] = [];
|
||||
let previousKey: Uint8Array = Buffer.alloc(0);
|
||||
for (const entry of entries) {
|
||||
const key = levelDbInternalKey(entry.sequence, entry.kind ?? 1);
|
||||
let shared = 0;
|
||||
while (shared < previousKey.length && previousKey[shared] === key[shared]) {
|
||||
shared += 1;
|
||||
}
|
||||
const value = Buffer.from(entry.value);
|
||||
encoded.push(
|
||||
encodeVarint(shared),
|
||||
encodeVarint(key.length - shared),
|
||||
encodeVarint(value.length),
|
||||
key.subarray(shared),
|
||||
value,
|
||||
);
|
||||
previousKey = key;
|
||||
}
|
||||
return Buffer.concat([
|
||||
...encoded,
|
||||
Buffer.alloc(4), // one restart at the first entry
|
||||
Buffer.from([1, 0, 0, 0]),
|
||||
]);
|
||||
}
|
||||
|
||||
function snappyGroupRecords(groupId: string, groupName: string, localSessionId: string): Buffer {
|
||||
const group = `{"id":"${groupId}","name":"${groupName}"}`;
|
||||
const assignmentPrefix = `{"code:${localSessionId}":"`;
|
||||
const key = levelDbInternalKey(1);
|
||||
const valueLength =
|
||||
Buffer.byteLength(group) + Buffer.byteLength(assignmentPrefix) + groupId.length + 2;
|
||||
const firstChunk = Buffer.concat([
|
||||
encodeVarint(0),
|
||||
encodeVarint(key.length),
|
||||
encodeVarint(valueLength),
|
||||
key,
|
||||
Buffer.from(`${group}${assignmentPrefix}`),
|
||||
]);
|
||||
const groupIdOffset = firstChunk.length - firstChunk.indexOf(groupId);
|
||||
const tail = Buffer.concat([Buffer.from('"}'), Buffer.alloc(4), Buffer.from([1, 0, 0, 0])]);
|
||||
const decodedLength = firstChunk.length + groupId.length + tail.length;
|
||||
return Buffer.concat([
|
||||
encodeVarint(decodedLength),
|
||||
snappyLiteralChunk(firstChunk),
|
||||
Buffer.from([((groupId.length - 1) << 2) | 2, groupIdOffset & 0xff, groupIdOffset >> 8]),
|
||||
snappyLiteralChunk(tail),
|
||||
]);
|
||||
}
|
||||
|
||||
function levelDbTable(data: Buffer, compression: 0 | 1): Buffer {
|
||||
const dataWithTrailer = Buffer.concat([data, Buffer.from([compression, 0, 0, 0, 0])]);
|
||||
const handle = Buffer.concat([encodeVarint(0), encodeVarint(data.length)]);
|
||||
const indexEntry = Buffer.concat([Buffer.from([0, 1, handle.length, 0x78]), handle]);
|
||||
const index = Buffer.concat([
|
||||
indexEntry,
|
||||
Buffer.alloc(4), // one restart at the start of the index block
|
||||
Buffer.from([1, 0, 0, 0]),
|
||||
]);
|
||||
const indexWithTrailer = Buffer.concat([index, Buffer.alloc(5)]);
|
||||
const footer = Buffer.alloc(48);
|
||||
Buffer.concat([
|
||||
encodeVarint(0),
|
||||
encodeVarint(0),
|
||||
encodeVarint(dataWithTrailer.length),
|
||||
encodeVarint(index.length),
|
||||
]).copy(footer);
|
||||
return Buffer.concat([dataWithTrailer, indexWithTrailer, footer]);
|
||||
}
|
||||
|
||||
async function writeDesktopGroupStore(
|
||||
home: string,
|
||||
groupId: string,
|
||||
groupName: string,
|
||||
localSessionId: string,
|
||||
): Promise<void> {
|
||||
const dir = path.join(
|
||||
home,
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"Local Storage",
|
||||
"leveldb",
|
||||
);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(dir, "000001.ldb"),
|
||||
levelDbTable(snappyGroupRecords(groupId, groupName, localSessionId), 1),
|
||||
);
|
||||
}
|
||||
|
||||
async function writeDesktopGroupStoreEntries(
|
||||
home: string,
|
||||
entries: Array<{ sequence: number; value: string | Buffer; kind?: number }>,
|
||||
): Promise<void> {
|
||||
const dir = path.join(
|
||||
home,
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"Local Storage",
|
||||
"leveldb",
|
||||
);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "000001.ldb"), levelDbTable(levelDbDataBlock(entries), 0));
|
||||
}
|
||||
|
||||
async function writeBrokenClaudeNpmShim(binDir: string): Promise<string> {
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
const executable = path.join(binDir, process.platform === "win32" ? "claude.cmd" : "claude");
|
||||
@@ -896,6 +1043,191 @@ describe("Claude session catalog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("imports a Claude Desktop custom group for its matching catalog row", async () => {
|
||||
const home = await createHome();
|
||||
const sessionId = "desktop-custom-group";
|
||||
const localSessionId = "local_11111111-1111-1111-1111-111111111111";
|
||||
await writeProject({
|
||||
home,
|
||||
entries: [
|
||||
{
|
||||
sessionId,
|
||||
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
|
||||
projectPath: "/work/openclaw",
|
||||
isSidechain: false,
|
||||
},
|
||||
],
|
||||
transcripts: { [sessionId]: [message(sessionId, "user", "custom group prompt", 1)] },
|
||||
});
|
||||
await writeDesktopMetadata(home, "custom-group", {
|
||||
sessionId: localSessionId,
|
||||
cliSessionId: sessionId,
|
||||
cwd: "/work/openclaw",
|
||||
title: "Desktop custom group",
|
||||
});
|
||||
await writeDesktopGroupStore(
|
||||
home,
|
||||
"cg-22222222-2222-2222-2222-222222222222",
|
||||
"Release",
|
||||
localSessionId,
|
||||
);
|
||||
|
||||
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
|
||||
sessions: [{ threadId: sessionId, customGroup: "Release", source: "claude-desktop" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips custom group names spliced with decoder garbage", async () => {
|
||||
const home = await createHome();
|
||||
const sessionId = "desktop-garbage-group";
|
||||
const localSessionId = "local_33333333-3333-3333-3333-333333333333";
|
||||
const groupId = "cg-44444444-4444-4444-4444-444444444444";
|
||||
await writeProject({
|
||||
home,
|
||||
entries: [
|
||||
{
|
||||
sessionId,
|
||||
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
|
||||
projectPath: "/work/openclaw",
|
||||
isSidechain: false,
|
||||
},
|
||||
],
|
||||
transcripts: { [sessionId]: [message(sessionId, "user", "garbage group prompt", 1)] },
|
||||
});
|
||||
await writeDesktopMetadata(home, "garbage-group", {
|
||||
sessionId: localSessionId,
|
||||
cliSessionId: sessionId,
|
||||
cwd: "/work/openclaw",
|
||||
title: "Desktop garbage group",
|
||||
});
|
||||
// Keep the control-byte guard as defense in depth for malformed decoded values.
|
||||
await writeDesktopGroupStoreEntries(home, [
|
||||
{
|
||||
sequence: 1,
|
||||
value:
|
||||
`{"id":"${groupId}","name":"Rele\u0012)\fase"}` +
|
||||
`{"id":"${groupId}","name":"Release"}` +
|
||||
`{"code:${localSessionId}":"${groupId}"}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
|
||||
sessions: [{ threadId: sessionId, customGroup: "Release", source: "claude-desktop" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the highest-sequence Claude Desktop custom group value", async () => {
|
||||
const home = await createHome();
|
||||
const sessionId = "desktop-newest-custom-group";
|
||||
const localSessionId = "local_55555555-5555-5555-5555-555555555555";
|
||||
const groupId = "cg-66666666-6666-6666-6666-666666666666";
|
||||
await writeProject({
|
||||
home,
|
||||
entries: [
|
||||
{
|
||||
sessionId,
|
||||
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
|
||||
projectPath: "/work/openclaw",
|
||||
isSidechain: false,
|
||||
},
|
||||
],
|
||||
transcripts: { [sessionId]: [message(sessionId, "user", "newest group prompt", 1)] },
|
||||
});
|
||||
await writeDesktopMetadata(home, "newest-custom-group", {
|
||||
sessionId: localSessionId,
|
||||
cliSessionId: sessionId,
|
||||
cwd: "/work/openclaw",
|
||||
title: "Desktop newest custom group",
|
||||
});
|
||||
await writeDesktopGroupStoreEntries(home, [
|
||||
{
|
||||
sequence: 1,
|
||||
value: `{"id":"${groupId}","name":"Old"}{"code:${localSessionId}":"${groupId}"}`,
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
value: `{"id":"${groupId}","name":"New"}{"code:${localSessionId}":"${groupId}"}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
|
||||
sessions: [{ threadId: sessionId, customGroup: "New", source: "claude-desktop" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads custom groups from a UTF-16 encoded Local Storage value", async () => {
|
||||
const home = await createHome();
|
||||
const sessionId = "desktop-utf16-group";
|
||||
const localSessionId = "local_77777777-7777-7777-7777-777777777777";
|
||||
const groupId = "cg-88888888-8888-8888-8888-888888888888";
|
||||
await writeProject({
|
||||
home,
|
||||
entries: [
|
||||
{
|
||||
sessionId,
|
||||
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
|
||||
projectPath: "/work/openclaw",
|
||||
isSidechain: false,
|
||||
},
|
||||
],
|
||||
transcripts: { [sessionId]: [message(sessionId, "user", "utf16 group prompt", 1)] },
|
||||
});
|
||||
await writeDesktopMetadata(home, "utf16-group", {
|
||||
sessionId: localSessionId,
|
||||
cliSessionId: sessionId,
|
||||
cwd: "/work/openclaw",
|
||||
title: "Desktop utf16 group",
|
||||
});
|
||||
// Chromium switches a whole value to UTF-16 when any character escapes Latin-1,
|
||||
// so the ASCII JSON arrives with interleaved NUL bytes.
|
||||
const records = `{"id":"${groupId}","name":"Release"}{"code:${localSessionId}":"${groupId}"}`;
|
||||
await writeDesktopGroupStoreEntries(home, [
|
||||
{ sequence: 1, value: Buffer.from(records, "utf16le") },
|
||||
]);
|
||||
|
||||
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
|
||||
sessions: [{ threadId: sessionId, customGroup: "Release", source: "claude-desktop" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops custom groups once a newer entry no longer carries them", async () => {
|
||||
const home = await createHome();
|
||||
const sessionId = "desktop-deleted-group";
|
||||
const localSessionId = "local_99999999-9999-9999-9999-999999999999";
|
||||
const groupId = "cg-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
await writeProject({
|
||||
home,
|
||||
entries: [
|
||||
{
|
||||
sessionId,
|
||||
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
|
||||
projectPath: "/work/openclaw",
|
||||
isSidechain: false,
|
||||
},
|
||||
],
|
||||
transcripts: { [sessionId]: [message(sessionId, "user", "deleted group prompt", 1)] },
|
||||
});
|
||||
await writeDesktopMetadata(home, "deleted-group", {
|
||||
sessionId: localSessionId,
|
||||
cliSessionId: sessionId,
|
||||
cwd: "/work/openclaw",
|
||||
title: "Desktop deleted group",
|
||||
});
|
||||
// Removing the last custom group rewrites the store without any records; the older
|
||||
// value must not win on sequence.
|
||||
await writeDesktopGroupStoreEntries(home, [
|
||||
{
|
||||
sequence: 1,
|
||||
value: `{"id":"${groupId}","name":"Release"}{"code:${localSessionId}":"${groupId}"}`,
|
||||
},
|
||||
{ sequence: 2, value: "{}" },
|
||||
]);
|
||||
|
||||
const page = await listLocalClaudeSessionPage({}, home);
|
||||
expect(page.sessions[0]).toMatchObject({ threadId: sessionId, source: "claude-desktop" });
|
||||
expect(page.sessions[0]).not.toHaveProperty("customGroup");
|
||||
});
|
||||
|
||||
it("rejects sidechain, unindexed, and symlink-escaped transcript ids", async () => {
|
||||
const home = await createHome();
|
||||
const projectDir = path.join(home, ".claude", "projects", "-workspace");
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SessionCatalogTranscriptItem,
|
||||
} from "openclaw/plugin-sdk/session-catalog";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { readClaudeDesktopCustomGroups } from "./claude-desktop-groups.js";
|
||||
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_MODEL_REF } from "./cli-constants.js";
|
||||
import {
|
||||
adoptedSessionKey,
|
||||
@@ -96,6 +97,7 @@ type DesktopSessionMetadata = {
|
||||
model?: unknown;
|
||||
isArchived?: unknown;
|
||||
title?: unknown;
|
||||
customGroup?: unknown;
|
||||
};
|
||||
|
||||
type CatalogRecord = ClaudeSessionCatalogSession & {
|
||||
@@ -217,6 +219,7 @@ async function readDesktopMetadata(homeDir: string): Promise<{
|
||||
}> {
|
||||
const active = new Map<string, DesktopSessionMetadata>();
|
||||
const archived = new Set<string>();
|
||||
const customGroups = await readClaudeDesktopCustomGroups(homeDir);
|
||||
for (const accountDir of await childDirectories(desktopSessionsDir(homeDir))) {
|
||||
for (const workspaceDir of await childDirectories(accountDir)) {
|
||||
let entries: string[];
|
||||
@@ -244,7 +247,9 @@ async function readDesktopMetadata(homeDir: string): Promise<{
|
||||
continue;
|
||||
}
|
||||
if (!archived.has(cliSessionId)) {
|
||||
active.set(cliSessionId, metadata);
|
||||
const localSessionId = optionalString(metadata.sessionId, 256);
|
||||
const customGroup = localSessionId ? customGroups.get(localSessionId) : undefined;
|
||||
active.set(cliSessionId, customGroup ? { ...metadata, customGroup } : metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,6 +586,7 @@ async function listClaudeSessions(homeDir = currentHomeDir()): Promise<CatalogRe
|
||||
}
|
||||
const createdAt = timestampMs(metadata.createdAt) ?? existing?.createdAt;
|
||||
const updatedAt = timestampMs(metadata.lastActivityAt) ?? existing?.updatedAt;
|
||||
const customGroup = optionalString(metadata.customGroup, 500);
|
||||
records.set(sessionId, {
|
||||
...(existing ?? {
|
||||
threadId: sessionId,
|
||||
@@ -592,6 +598,7 @@ async function listClaudeSessions(homeDir = currentHomeDir()): Promise<CatalogRe
|
||||
cwd: optionalString(metadata.cwd) ?? optionalString(metadata.originCwd) ?? existing?.cwd,
|
||||
...(createdAt !== undefined ? { createdAt } : {}),
|
||||
...(updatedAt !== undefined ? { updatedAt, recencyAt: updatedAt } : {}),
|
||||
...(customGroup ? { customGroup } : {}),
|
||||
source: "claude-desktop",
|
||||
filePath,
|
||||
});
|
||||
@@ -1448,6 +1455,7 @@ function toGenericClaudeHost(
|
||||
modelProvider: session.modelProvider,
|
||||
...(session.cliVersion ? { cliVersion: session.cliVersion } : {}),
|
||||
...(session.gitBranch ? { gitBranch: session.gitBranch } : {}),
|
||||
...(session.customGroup ? { customGroup: session.customGroup } : {}),
|
||||
archived: session.archived,
|
||||
...(continuable && existingSessionKey ? { sessionKey: existingSessionKey } : {}),
|
||||
canContinue: continuable,
|
||||
|
||||
@@ -37,6 +37,7 @@ export const SessionCatalogSessionSchema = closedObject({
|
||||
modelProvider: Type.Optional(Type.String()),
|
||||
cliVersion: Type.Optional(Type.String()),
|
||||
gitBranch: Type.Optional(Type.String()),
|
||||
customGroup: Type.Optional(Type.String()),
|
||||
archived: Type.Boolean(),
|
||||
sessionKey: Type.Optional(NonEmptyString),
|
||||
canContinue: Type.Boolean(),
|
||||
|
||||
@@ -30,6 +30,27 @@ describe("groupCatalogSessionsByProject", () => {
|
||||
expect(result.groups[0]?.sessions.map((item) => item.threadId)).toEqual(["b-1", "b-2"]);
|
||||
});
|
||||
|
||||
it("uses a custom group before the session project", () => {
|
||||
const result = groupCatalogSessionsByProject([
|
||||
{ ...session("grouped", "/work/openclaw"), customGroup: "Release" },
|
||||
session("project", "/work/openclaw"),
|
||||
]);
|
||||
|
||||
expect(result.groups).toMatchObject([
|
||||
{ key: "custom:Release", label: "Release", sessions: [{ threadId: "grouped" }] },
|
||||
{ key: "/work/openclaw", label: "openclaw", sessions: [{ threadId: "project" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts custom groups ahead of project groups regardless of session order", () => {
|
||||
const result = groupCatalogSessionsByProject([
|
||||
session("project", "/work/openclaw"),
|
||||
{ ...session("grouped", "/work/openclaw"), customGroup: "Release" },
|
||||
]);
|
||||
|
||||
expect(result.groups.map((group) => group.key)).toEqual(["custom:Release", "/work/openclaw"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/Users/dev/openclaw/.claude/worktrees/fix-1", "/Users/dev/openclaw"],
|
||||
["/Users/dev/openclaw/.claude/worktrees/fix-1/ui/src", "/Users/dev/openclaw"],
|
||||
|
||||
@@ -17,11 +17,32 @@ export function groupCatalogSessionsByProject(sessions: readonly SessionCatalogS
|
||||
groups: CatalogProjectGroup[];
|
||||
ungrouped: SessionCatalogSession[];
|
||||
} {
|
||||
const groups: CatalogProjectGroup[] = [];
|
||||
// Custom groups are collected separately so they sort ahead of project groups
|
||||
// regardless of session order; interleaving by first-seen would make section
|
||||
// order depend on the roster's sort.
|
||||
const customGroups: CatalogProjectGroup[] = [];
|
||||
const projectGroups: CatalogProjectGroup[] = [];
|
||||
const groupsByPath = new Map<string, CatalogProjectGroup>();
|
||||
const ungrouped: SessionCatalogSession[] = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const customGroup = session.customGroup?.trim();
|
||||
if (customGroup) {
|
||||
const key = `custom:${customGroup}`;
|
||||
let group = groupsByPath.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
key,
|
||||
label: customGroup,
|
||||
title: `Custom group: ${customGroup}`,
|
||||
sessions: [],
|
||||
};
|
||||
groupsByPath.set(key, group);
|
||||
customGroups.push(group);
|
||||
}
|
||||
group.sessions.push(session);
|
||||
continue;
|
||||
}
|
||||
// Accepted tradeoff: filesystem-root cwds ("/", "C:\") are not real harness
|
||||
// session roots; after trimming they fall to the ungrouped flat tail by design.
|
||||
let projectPath = session.cwd?.trim().replace(/[\\/]+$/, "");
|
||||
@@ -46,10 +67,10 @@ export function groupCatalogSessionsByProject(sessions: readonly SessionCatalogS
|
||||
sessions: [],
|
||||
};
|
||||
groupsByPath.set(projectPath, group);
|
||||
groups.push(group);
|
||||
projectGroups.push(group);
|
||||
}
|
||||
group.sessions.push(session);
|
||||
}
|
||||
|
||||
return { groups, ungrouped };
|
||||
return { groups: [...customGroups, ...projectGroups], ungrouped };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user