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:
Peter Steinberger
2026-07-20 00:30:37 -07:00
committed by GitHub
parent a971188a6d
commit 248726fafd
9 changed files with 720 additions and 5 deletions
@@ -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"
+1 -1
View File
@@ -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");
+9 -1
View File
@@ -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 };
}