@co0ontty/wand 4.31.0 → 4.32.0-beta.gefb23cd

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "963337ac2ab326332080b2ca10b39f1ccce64665",
3
- "builtAt": "2026-08-06T12:29:30.333Z",
4
- "version": "4.31.0",
5
- "channel": "stable"
2
+ "commit": "efb23cdde52eafc12024c59c5c1259d73c2054fa",
3
+ "builtAt": "2026-08-06T23:29:50.325Z",
4
+ "version": "4.32.0-beta.gefb23cd",
5
+ "channel": "beta"
6
6
  }
@@ -55,11 +55,14 @@ export interface SessionDirectoryTreeResponse {
55
55
  roots: SessionDirectoryNode<SessionListPageEntry>[];
56
56
  totalSessions: number;
57
57
  directoryCount: number;
58
+ /** Session-entry revision, kept identical to /api/session-list for pagination compatibility. */
58
59
  revision: string;
60
+ /** Full directory-tree revision, including custom workspace names. */
61
+ treeRevision: string;
59
62
  }
60
63
  export declare function buildSessionListEntries(sessions: SessionSnapshot[], claudeHistory: ProviderHistorySession[], codexHistory: ProviderHistorySession[], hiddenHistoryIds: Set<string>, openCodeHistory?: ProviderHistorySession[], qoderHistory?: ProviderHistorySession[]): SessionListPageEntry[];
61
64
  export declare function buildSessionListPage(sessions: SessionSnapshot[], claudeHistory: ProviderHistorySession[], codexHistory: ProviderHistorySession[], hiddenHistoryIds: Set<string>, offset: number, limit: number, openCodeHistory?: ProviderHistorySession[], qoderHistory?: ProviderHistorySession[]): SessionListPage;
62
- export declare function buildSessionDirectoryTree(sessions: SessionSnapshot[], claudeHistory: ProviderHistorySession[], codexHistory: ProviderHistorySession[], hiddenHistoryIds: Set<string>, openCodeHistory?: ProviderHistorySession[], qoderHistory?: ProviderHistorySession[]): SessionDirectoryTreeResponse;
65
+ export declare function buildSessionDirectoryTree(sessions: SessionSnapshot[], claudeHistory: ProviderHistorySession[], codexHistory: ProviderHistorySession[], hiddenHistoryIds: Set<string>, openCodeHistory?: ProviderHistorySession[], qoderHistory?: ProviderHistorySession[], customNames?: ReadonlyMap<string, string>): SessionDirectoryTreeResponse;
63
66
  /**
64
67
  * Provider history is scanned by ProcessManager, but structured sessions live
65
68
  * in StructuredSessionManager. Annotate against the combined session list so a
@@ -12,7 +12,7 @@ import { buildProviderResumeCommand, isProviderSessionId, isSafeProviderSessionI
12
12
  import { parseBoundedInteger } from "./request-limits.js";
13
13
  import { asyncRoute } from "./express-async.js";
14
14
  import { enrichStructuredMessages, WAND_PROTOCOL_VERSION } from "./structured-client-protocol.js";
15
- import { buildDirectoryTree, } from "./session-directory-tree.js";
15
+ import { buildDirectoryTree, normalizeSessionDirectory, } from "./session-directory-tree.js";
16
16
  export function parseExecutionMode(value, fallback) {
17
17
  if (value === undefined)
18
18
  return fallback;
@@ -228,9 +228,16 @@ export function buildSessionListEntries(sessions, claudeHistory, codexHistory, h
228
228
  return timestampOrder || left.key.localeCompare(right.key);
229
229
  });
230
230
  }
231
- function sessionListRevision(entries) {
231
+ function sessionListRevision(entries, directoryNames = []) {
232
+ const entryState = entries.map((entry) => [entry.key, entry.sortTimestamp]);
233
+ const revisionState = directoryNames.length === 0
234
+ ? entryState
235
+ : {
236
+ entries: entryState,
237
+ directoryNames: directoryNames.slice().sort(([left], [right]) => left.localeCompare(right)),
238
+ };
232
239
  return createHash("sha256")
233
- .update(JSON.stringify(entries.map((entry) => [entry.key, entry.sortTimestamp])))
240
+ .update(JSON.stringify(revisionState))
234
241
  .digest("base64url");
235
242
  }
236
243
  export function buildSessionListPage(sessions, claudeHistory, codexHistory, hiddenHistoryIds, offset, limit, openCodeHistory = [], qoderHistory = []) {
@@ -244,14 +251,31 @@ export function buildSessionListPage(sessions, claudeHistory, codexHistory, hidd
244
251
  revision,
245
252
  };
246
253
  }
247
- export function buildSessionDirectoryTree(sessions, claudeHistory, codexHistory, hiddenHistoryIds, openCodeHistory = [], qoderHistory = []) {
254
+ export function buildSessionDirectoryTree(sessions, claudeHistory, codexHistory, hiddenHistoryIds, openCodeHistory = [], qoderHistory = [], customNames = new Map()) {
248
255
  const entries = buildSessionListEntries(sessions, claudeHistory, codexHistory, hiddenHistoryIds, openCodeHistory, qoderHistory);
249
256
  const tree = buildDirectoryTree(entries.map((entry) => ({
250
257
  entry,
251
258
  cwd: entry.type === "managed" ? entry.session.cwd : entry.history.cwd,
252
259
  sortTimestamp: entry.sortTimestamp,
253
- })));
254
- return { ...tree, revision: sessionListRevision(entries) };
260
+ })), "未知目录", customNames);
261
+ const appliedCustomNames = [];
262
+ const collectCustomNames = (nodes) => {
263
+ for (const node of nodes) {
264
+ if (node.customName)
265
+ appliedCustomNames.push([node.path, node.customName]);
266
+ collectCustomNames(node.children);
267
+ }
268
+ };
269
+ collectCustomNames(tree.roots);
270
+ return {
271
+ ...tree,
272
+ revision: sessionListRevision(entries),
273
+ treeRevision: sessionListRevision(entries, appliedCustomNames),
274
+ };
275
+ }
276
+ function directoryTreeContainsPath(nodes, directoryPath) {
277
+ return nodes.some((node) => ((!node.synthetic && node.path === directoryPath)
278
+ || directoryTreeContainsPath(node.children, directoryPath)));
255
279
  }
256
280
  /**
257
281
  * Provider history is scanned by ProcessManager, but structured sessions live
@@ -413,6 +437,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
413
437
  messageTotal: windowed.messageTotal,
414
438
  });
415
439
  };
440
+ const currentDirectoryTree = () => buildSessionDirectoryTree(sessions.listSlim(), processes.listClaudeHistorySessions(), processes.listCodexHistorySessions(), getHiddenClaudeSessionIds(storage), processes.listOpenCodeHistorySessions(), processes.listQoderHistorySessions(), storage.listSessionDirectoryNames());
416
441
  app.get("/api/session-list", (req, res) => {
417
442
  try {
418
443
  const offset = parseBoundedInteger(req.query.offset, 0, 0, Number.MAX_SAFE_INTEGER);
@@ -432,12 +457,49 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
432
457
  });
433
458
  app.get("/api/session-directories", (_req, res) => {
434
459
  try {
435
- res.json(buildSessionDirectoryTree(sessions.listSlim(), processes.listClaudeHistorySessions(), processes.listCodexHistorySessions(), getHiddenClaudeSessionIds(storage), processes.listOpenCodeHistorySessions(), processes.listQoderHistorySessions()));
460
+ res.json(currentDirectoryTree());
436
461
  }
437
462
  catch (error) {
438
463
  res.status(500).json({ error: getErrorMessage(error, "无法加载会话目录。") });
439
464
  }
440
465
  });
466
+ app.put("/api/session-directories/name", (req, res) => {
467
+ const body = req.body;
468
+ if (typeof body?.path !== "string") {
469
+ res.status(400).json({ error: "path 必须是当前工作区的目录路径。" });
470
+ return;
471
+ }
472
+ const directoryPath = normalizeSessionDirectory(body.path);
473
+ if (!directoryPath) {
474
+ res.status(400).json({ error: "不能重命名未知目录。" });
475
+ return;
476
+ }
477
+ if (body.name !== null && typeof body.name !== "string") {
478
+ res.status(400).json({ error: "name 必须是字符串或 null。" });
479
+ return;
480
+ }
481
+ const customName = typeof body.name === "string" ? body.name.trim() : "";
482
+ if (Array.from(customName).length > 80) {
483
+ res.status(400).json({ error: "工作区名称不能超过 80 个字符。" });
484
+ return;
485
+ }
486
+ if (/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/u.test(customName)) {
487
+ res.status(400).json({ error: "工作区名称不能包含换行或控制字符。" });
488
+ return;
489
+ }
490
+ try {
491
+ const tree = currentDirectoryTree();
492
+ if (!directoryTreeContainsPath(tree.roots, directoryPath)) {
493
+ res.status(404).json({ error: "未找到该会话目录。" });
494
+ return;
495
+ }
496
+ storage.setSessionDirectoryName(directoryPath, customName || null);
497
+ res.json({ ok: true, path: directoryPath, name: customName || null });
498
+ }
499
+ catch (error) {
500
+ res.status(500).json({ error: getErrorMessage(error, "无法保存工作区名称。") });
501
+ }
502
+ });
441
503
  app.get("/api/sessions", (_req, res) => {
442
504
  res.json(sessions.listSlim().map(toSessionListItemDTO));
443
505
  });
package/dist/server.js CHANGED
@@ -696,6 +696,7 @@ export async function startServer(config, configPath, options = {}) {
696
696
  "/api/config",
697
697
  "/api/models",
698
698
  "/api/sessions",
699
+ "/api/session-directories",
699
700
  "/api/structured-sessions",
700
701
  "/api/commands",
701
702
  "/api/claude-skills",
@@ -8,6 +8,8 @@ export interface SessionDirectoryNode<T> {
8
8
  path: string;
9
9
  /** Compact path segment label. Single-child ancestors are folded into this label. */
10
10
  name: string;
11
+ /** User-defined workspace label. Clients display this in preference to name. */
12
+ customName?: string;
11
13
  synthetic: boolean;
12
14
  /** Sessions whose cwd is exactly this node. */
13
15
  directCount: number;
@@ -24,4 +26,4 @@ export interface SessionDirectoryTree<T> {
24
26
  directoryCount: number;
25
27
  }
26
28
  export declare function normalizeSessionDirectory(value: string): string;
27
- export declare function buildDirectoryTree<T>(sources: readonly SessionDirectorySource<T>[], unknownLabel?: string): SessionDirectoryTree<T>;
29
+ export declare function buildDirectoryTree<T>(sources: readonly SessionDirectorySource<T>[], unknownLabel?: string, customNames?: ReadonlyMap<string, string>): SessionDirectoryTree<T>;
@@ -60,14 +60,16 @@ function compactLabel(parent, child, childPath) {
60
60
  return `${parent}${child}`;
61
61
  return `${parent}${separator}${child}`;
62
62
  }
63
- function finalizeNode(node) {
64
- let children = [...node.children.values()].map(finalizeNode);
63
+ function finalizeNode(node, customNames) {
64
+ let children = [...node.children.values()].map((child) => finalizeNode(child, customNames));
65
65
  const sortedEntries = node.entries
66
66
  .slice()
67
67
  .sort((left, right) => right.sortTimestamp - left.sortTimestamp);
68
+ const customName = customNames.get(node.path);
68
69
  let result = {
69
70
  path: node.path,
70
71
  name: node.name,
72
+ ...(customName ? { customName } : {}),
71
73
  synthetic: node.synthetic,
72
74
  directCount: sortedEntries.length,
73
75
  totalCount: sortedEntries.length + children.reduce((sum, child) => sum + child.totalCount, 0),
@@ -77,7 +79,7 @@ function finalizeNode(node) {
77
79
  };
78
80
  // A filesystem root followed by a single unambiguous chain is visual noise in
79
81
  // a 280-300px sidebar. Fold it while retaining the exact descendant path.
80
- while (!result.synthetic && result.directCount === 0 && result.children.length === 1) {
82
+ while (!result.synthetic && !result.customName && result.directCount === 0 && result.children.length === 1) {
81
83
  const child = result.children[0];
82
84
  result = {
83
85
  ...child,
@@ -86,11 +88,11 @@ function finalizeNode(node) {
86
88
  }
87
89
  children = result.children.slice().sort((left, right) => {
88
90
  const latestOrder = right.latestTimestamp - left.latestTimestamp;
89
- return latestOrder || left.name.localeCompare(right.name);
91
+ return latestOrder || (left.customName ?? left.name).localeCompare(right.customName ?? right.name);
90
92
  });
91
93
  return { ...result, children };
92
94
  }
93
- export function buildDirectoryTree(sources, unknownLabel = "未知目录") {
95
+ export function buildDirectoryTree(sources, unknownLabel = "未知目录", customNames = new Map()) {
94
96
  const roots = new Map();
95
97
  let unknown = null;
96
98
  const realDirectories = new Set();
@@ -113,14 +115,14 @@ export function buildDirectoryTree(sources, unknownLabel = "未知目录") {
113
115
  }
114
116
  node.entries.push({ entry: source.entry, sortTimestamp: source.sortTimestamp });
115
117
  }
116
- const finalized = [...roots.values()].map(finalizeNode);
118
+ const finalized = [...roots.values()].map((node) => finalizeNode(node, customNames));
117
119
  if (unknown)
118
- finalized.push(finalizeNode(unknown));
120
+ finalized.push(finalizeNode(unknown, customNames));
119
121
  finalized.sort((left, right) => {
120
122
  if (left.synthetic !== right.synthetic)
121
123
  return left.synthetic ? 1 : -1;
122
124
  const latestOrder = right.latestTimestamp - left.latestTimestamp;
123
- return latestOrder || left.name.localeCompare(right.name);
125
+ return latestOrder || (left.customName ?? left.name).localeCompare(right.customName ?? right.name);
124
126
  });
125
127
  return {
126
128
  roots: finalized,
package/dist/storage.d.ts CHANGED
@@ -36,6 +36,10 @@ export declare class WandStorage {
36
36
  setPreference<T>(key: string, value: T | null | undefined): void;
37
37
  /** 判断偏好是否在 DB 中存在(区别于值为 null/false/"")。 */
38
38
  hasPreference(key: string): boolean;
39
+ /** Return user-defined workspace labels keyed by normalized session cwd. */
40
+ listSessionDirectoryNames(): Map<string, string>;
41
+ /** Set a workspace label, or remove it when name is null/blank. */
42
+ setSessionDirectoryName(directoryPath: string, name: string | null): void;
39
43
  /** Get password from database */
40
44
  getPassword(): string | null;
41
45
  /** Set password in database */
package/dist/storage.js CHANGED
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import { chmodSync, existsSync, mkdirSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
+ import { normalizeSessionDirectory } from "./session-directory-tree.js";
5
6
  import { DEFAULT_PASSWORD_VAULT_ID, DEFAULT_PASSWORD_VAULT_NAME, itemMatchesFilter, normalizePasswordItemInput, normalizeVaultName, nowIso, } from "./password-manager.js";
6
7
  function safeJsonParse(raw) {
7
8
  if (!raw)
@@ -450,6 +451,12 @@ const INIT_SQL = `
450
451
  value TEXT NOT NULL
451
452
  );
452
453
 
454
+ CREATE TABLE IF NOT EXISTS session_directory_names (
455
+ path TEXT PRIMARY KEY,
456
+ name TEXT NOT NULL,
457
+ updated_at TEXT NOT NULL
458
+ );
459
+
453
460
  CREATE TABLE IF NOT EXISTS password_vaults (
454
461
  id TEXT PRIMARY KEY,
455
462
  name TEXT NOT NULL,
@@ -641,6 +648,32 @@ export class WandStorage {
641
648
  hasPreference(key) {
642
649
  return this.getConfigValue(key) !== null;
643
650
  }
651
+ // ============ Session Directory Names ============
652
+ /** Return user-defined workspace labels keyed by normalized session cwd. */
653
+ listSessionDirectoryNames() {
654
+ const rows = this.db
655
+ .prepare("SELECT path, name FROM session_directory_names ORDER BY path ASC")
656
+ .all();
657
+ return new Map(rows.map((row) => [row.path, row.name]));
658
+ }
659
+ /** Set a workspace label, or remove it when name is null/blank. */
660
+ setSessionDirectoryName(directoryPath, name) {
661
+ const normalizedPath = normalizeSessionDirectory(directoryPath);
662
+ if (!normalizedPath)
663
+ throw new Error("会话目录路径不能为空。");
664
+ const normalizedName = name?.trim() ?? "";
665
+ if (!normalizedName) {
666
+ this.db.prepare("DELETE FROM session_directory_names WHERE path = ?").run(normalizedPath);
667
+ return;
668
+ }
669
+ this.db
670
+ .prepare(`INSERT INTO session_directory_names (path, name, updated_at)
671
+ VALUES (?, ?, ?)
672
+ ON CONFLICT(path) DO UPDATE SET
673
+ name = excluded.name,
674
+ updated_at = excluded.updated_at`)
675
+ .run(normalizedPath, normalizedName, nowIso());
676
+ }
644
677
  /** Get password from database */
645
678
  getPassword() {
646
679
  return this.getConfigValue("password");