dsh-ws-recency 0.10.6 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +22 -2
  2. package/client.js +136 -0
  3. package/index.js +175 -2
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-ws-recency
2
2
 
3
- DSH 侧栏的六件事:**工作区分组按最近活跃排序**、**过期历史自动折叠**、**运行中/未读/需跟进三色标记**、**所有置顶会话汇总到顶部一个区**、**删除会话到可恢复的回收站**、**按工具分类的 token 消耗统计**。单独装上就有效,不依赖其它插件。
3
+ DSH 侧栏的六件事:**工作区分组按最近活跃排序**、**过期历史自动折叠**、**运行中/未读/需跟进三色标记**、**所有置顶会话汇总到顶部一个区**、**删除会话到可恢复的回收站**、**按工具分类的 token 消耗统计**、**把会话移动到另一个工作区**。单独装上就有效,不依赖其它插件。
4
4
 
5
5
  ## 一、分组排序
6
6
 
@@ -75,6 +75,26 @@ DSH 原生的置顶只把会话顶到**它所属工作区分组的第一行**,
75
75
 
76
76
  宿主半(`index.js`)把统计与删除的路由挂在 webServer 上;如果面板报“宿主半未加载”,说明应用还没重启加载新版本。0.10.1 起面板失败时会带上 HTTP 状态和 `/ws-recency/health` 的自检结果,便于定位。
77
77
 
78
+ ## 七、移动到工作区
79
+
80
+ 「…」菜单 →「移动到工作区…」,选目标工作区即可。
81
+
82
+ **为什么核心做不到**:会话属于哪个工作区由它创建时的 `cwd` 决定,工作区的 attach 会校验「header.cwd 必须等于工作区路径」,而 header 是日志里不可变的第一条记录——所以在核心层面没有任何"改工作区"的入口。
83
+
84
+ **本插件怎么做**(宿主侧,唯一的写操作):
85
+
86
+ 1. 停掉会话;
87
+ 2. 通过 persistence 读出全部事件;
88
+ 3. 把旧日志目录移进 `.trash/ws-recency/<id>-move-<时间戳>`(既是备份,也腾出这个 id);
89
+ 4. 用新 `cwd`、原 id 重建日志,再回放全部事件;
90
+ 5. 校验新 header 的 cwd;
91
+ 6. 先 attach 到目标工作区(失败就回滚原目录),再 detach 旧工作区;
92
+ 7. 删投影缓存,让它按新路径重建。
93
+
94
+ **只移动会话**:对话框里明确提示——它在旧工作区里创建/修改的文件不会跟着走,仍留在原目录(附件与 rewind 快照按会话 id 存,不受影响)。
95
+
96
+ **风险与退路**:这是插件里唯一改写会话存储的操作。备份会保留,回滚 = 把备份目录移回 `sessions/<project>/<id>`。
97
+
78
98
  ## 设置
79
99
 
80
100
  **设置 → 通用**:
@@ -110,7 +130,7 @@ dsh plugin --profile <profile> add github:chyxhtc/dsh-ws-recency
110
130
  或固定版本的预构建包:
111
131
 
112
132
  ```powershell
113
- dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.10.6/dsh-ws-recency-0.10.6.tgz
133
+ dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.11.1/dsh-ws-recency-0.11.1.tgz
114
134
  ```
115
135
 
116
136
  装完把 `dsh-ws-recency` 加进 `~/.hypers-work/profiles/<profile>/package.json` 的 `dsh.profile.bundles`,重启应用。
package/client.js CHANGED
@@ -123,6 +123,15 @@
123
123
  ".wsr-stats-legend{display:flex;flex-wrap:wrap;gap:10px}",
124
124
  ".wsr-stats-legend-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--dsw-alias-label-tertiary)}",
125
125
  ".wsr-stats-swatch{width:8px;height:8px;border-radius:2px}",
126
+ ".wsr-move-warning{color:var(--dsw-alias-state-warn-primary,#d9a72c);font-size:11px;line-height:1.5}",
127
+ ".wsr-move-list{display:flex;flex-direction:column;gap:4px}",
128
+ ".wsr-move-row{display:flex;align-items:center;gap:8px;text-align:left;border:none;background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.04));color:inherit;font:inherit;padding:8px 10px;border-radius:8px;cursor:pointer}",
129
+ ".wsr-move-row:hover:not([disabled]){background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08))}",
130
+ ".wsr-move-row[disabled]{opacity:.55;cursor:default}",
131
+ ".wsr-move-text{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}",
132
+ ".wsr-move-title{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
133
+ ".wsr-move-path{font-size:11px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
134
+ ".wsr-move-badge{font-size:11px;color:var(--dsw-alias-label-tertiary)}",
126
135
  ".wsr-chart{display:flex;flex-direction:column;gap:6px}",
127
136
  ".wsr-chart-title{font-size:11px;color:var(--dsw-alias-label-tertiary)}",
128
137
  ".wsr-chart-row{display:grid;grid-template-columns:120px 1fr 64px 56px;align-items:center;gap:8px}",
@@ -195,6 +204,15 @@
195
204
  "stats.agentDetail": "主 agent 构成:思考 {reasoning} · 回复 {text} · 用户消息 {user} · 系统提示 {system}",
196
205
  "stats.diagnose.unmounted": "\n\n(宿主半已加载,但统计路由没挂上——重启应用再试)",
197
206
  "stats.diagnose.noHost": "\n\n(宿主半未加载:需要重启应用加载新版本)",
207
+ "menu.move": "移动到工作区…",
208
+ "move.title": "移动到工作区",
209
+ "move.warning": "只移动会话本身:它在旧工作区里创建或修改的文件不会跟着走,仍留在原目录。",
210
+ "move.note": "会话属于哪个工作区,由它创建时的目录决定;移动会改写会话记录的 cwd,把日志搬到新工作区目录,旧日志备份在 .trash/ws-recency。",
211
+ "move.confirm": "把会话移动到「{workspace}」?\n\n只移动会话记录,旧工作区里的文件不会跟着搬。",
212
+ "move.current": "当前",
213
+ "move.working": "移动中…(会先停止会话)",
214
+ "move.done": "已移动到「{workspace}」。若列表没刷新,切换一下工作区或重启应用。",
215
+ "move.failed": "移动失败:",
198
216
  "stats.chart": "消耗分布",
199
217
  "stats.details.show": "显示明细 ▾",
200
218
  "stats.details.hide": "收起明细 ▴",
@@ -239,6 +257,15 @@
239
257
  "stats.agentDetail": "Agent composition: reasoning {reasoning} \u00b7 reply {text} \u00b7 user messages {user} \u00b7 system prompt {system}",
240
258
  "stats.diagnose.unmounted": "\n\n(the host half is loaded but the statistics route did not mount - restart the app and retry)",
241
259
  "stats.diagnose.noHost": "\n\n(the host half is not loaded: restart the app to pick up the new version)",
260
+ "menu.move": "Move to workspace\u2026",
261
+ "move.title": "Move to workspace",
262
+ "move.warning": "Only the session moves: files it created or changed in the old workspace stay where they are.",
263
+ "move.note": "A session belongs to the workspace of the directory it was created in. Moving rewrites the stored cwd, relocates the log and keeps the old one as a backup in .trash/ws-recency.",
264
+ "move.confirm": "Move this session to \"{workspace}\"?\n\nOnly the session record moves; its files stay in the old workspace.",
265
+ "move.current": "current",
266
+ "move.working": "Moving\u2026 (the session is stopped first)",
267
+ "move.done": "Moved to \"{workspace}\". If the list has not refreshed, switch workspaces or restart the app.",
268
+ "move.failed": "Move failed: ",
242
269
  "stats.chart": "Where the tokens went",
243
270
  "stats.details.show": "Show details",
244
271
  "stats.details.hide": "Hide details",
@@ -1354,6 +1381,105 @@
1354
1381
  });
1355
1382
  }
1356
1383
 
1384
+ /** Which workspace currently owns a session, per the client snapshot. */
1385
+ function workspaceOfSession(sessionId) {
1386
+ var items = (data.workspaces && data.workspaces.items) || [];
1387
+ for (var i = 0; i < items.length; i += 1) {
1388
+ if ((items[i].sessionIds || []).indexOf(sessionId) !== -1) return items[i];
1389
+ }
1390
+ return null;
1391
+ }
1392
+
1393
+ /** Modal that lists the workspaces and moves the session into the chosen one. */
1394
+ function openMovePanel(sessionId) {
1395
+ if (typeof document === "undefined") return;
1396
+ closeStatsPanel();
1397
+ var backdrop = statsDiv("wsr-modal-backdrop");
1398
+ var panel = statsDiv("wsr-modal");
1399
+ backdrop.appendChild(panel);
1400
+ var head = statsDiv("wsr-modal-head");
1401
+ head.appendChild(statsDiv("wsr-modal-title", t("move.title")));
1402
+ var close = document.createElement("button");
1403
+ close.type = "button";
1404
+ close.className = "wsr-modal-close";
1405
+ close.textContent = "\u00d7";
1406
+ close.addEventListener("click", closeStatsPanel);
1407
+ head.appendChild(close);
1408
+ panel.appendChild(head);
1409
+ var body = statsDiv("wsr-modal-body");
1410
+ panel.appendChild(body);
1411
+ body.appendChild(statsDiv("wsr-stats-note", t("move.note")));
1412
+ body.appendChild(statsDiv("wsr-move-warning", t("move.warning")));
1413
+
1414
+ var current = workspaceOfSession(sessionId);
1415
+ var items = (data.workspaces && data.workspaces.items) || [];
1416
+ var list = statsDiv("wsr-move-list");
1417
+ for (var i = 0; i < items.length; i += 1) {
1418
+ (function (item) {
1419
+ var isCurrent = current !== null && current.workspaceId === item.workspaceId;
1420
+ var row = document.createElement("button");
1421
+ row.type = "button";
1422
+ row.className = "wsr-move-row";
1423
+ row.setAttribute("data-wsr-move-target", String(item.workspaceId));
1424
+ if (isCurrent) row.setAttribute("disabled", "true");
1425
+ var text = statsDiv("wsr-move-text");
1426
+ text.appendChild(statsDiv("wsr-move-title", (item.title && String(item.title)) || baseName(item.path)));
1427
+ text.appendChild(statsDiv("wsr-move-path", String(item.path || "")));
1428
+ row.appendChild(text);
1429
+ row.appendChild(statsDiv("wsr-move-badge", isCurrent ? t("move.current") : ""));
1430
+ if (!isCurrent) {
1431
+ row.addEventListener("click", function () {
1432
+ var label = (item.title && String(item.title)) || baseName(item.path);
1433
+ if (typeof window.confirm !== "function" || !window.confirm(t("move.confirm", { workspace: label }))) return;
1434
+ body.textContent = t("move.working");
1435
+ fetch("/ws-recency/session-move?sessionId=" + encodeURIComponent(sessionId), {
1436
+ method: "POST",
1437
+ headers: { "content-type": "application/json" },
1438
+ body: JSON.stringify({ sessionId: sessionId, workspaceId: item.workspaceId }),
1439
+ })
1440
+ .then(function (response) {
1441
+ return response.text().then(function (text) {
1442
+ var parsed = null;
1443
+ try { parsed = JSON.parse(text); } catch (error) { parsed = null; }
1444
+ return { status: response.status, text: text, json: parsed };
1445
+ });
1446
+ })
1447
+ .then(function (result) {
1448
+ if (result.json !== null && result.json.ok === true) {
1449
+ body.textContent = t("move.done", { workspace: label });
1450
+ return;
1451
+ }
1452
+ var reason = (result.json !== null && result.json.error) || (result.text || "").trim().slice(0, 160) || "(empty response)";
1453
+ body.textContent = t("move.failed") + "HTTP " + result.status + " \u00b7 " + reason;
1454
+ })
1455
+ .catch(function (error) {
1456
+ body.textContent = t("move.failed") + (error && error.message ? error.message : error);
1457
+ });
1458
+ });
1459
+ }
1460
+ list.appendChild(row);
1461
+ })(items[i]);
1462
+ }
1463
+ body.appendChild(list);
1464
+ backdrop.addEventListener("click", function (event) {
1465
+ if (event !== undefined && event.target === backdrop) closeStatsPanel();
1466
+ });
1467
+ document.body.appendChild(backdrop);
1468
+ statsModal = backdrop;
1469
+ document.addEventListener("keydown", onStatsKey);
1470
+ }
1471
+
1472
+ /** Session "..." menu row: move the conversation to another workspace. */
1473
+ function MoveMenuItem(props) {
1474
+ var Item = ui.MenuItemButton;
1475
+ if (typeof Item !== "function") return null;
1476
+ var Icon = ui.IconFolderOpenOutlineRegular;
1477
+ return h(Item, {
1478
+ icon: typeof Icon === "function" ? h(Icon, {}) : undefined,
1479
+ onSelect: function () { openMovePanel(props.sessionId); },
1480
+ }, t("menu.move"));
1481
+ }
1482
+
1357
1483
  /** Session "..." menu row: token statistics of that session. */
1358
1484
  function StatsMenuItem(props) {
1359
1485
  var Item = ui.MenuItemButton;
@@ -1600,6 +1726,16 @@
1600
1726
  }, FollowUpDot);
1601
1727
  });
1602
1728
 
1729
+ ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
1730
+ return ctx.slots.register({
1731
+ name: "sidebar.workspaces.session.menu.item",
1732
+ id: "ws-recency-move",
1733
+ order: 690,
1734
+ store: prefs,
1735
+ locale: NS,
1736
+ }, MoveMenuItem);
1737
+ });
1738
+
1603
1739
  ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
1604
1740
  return ctx.slots.register({
1605
1741
  name: "sidebar.workspaces.session.menu.item",
package/index.js CHANGED
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * @module dsh-ws-recency
21
21
  */
22
- import { mkdir, readdir, rename } from "node:fs/promises";
22
+ import { mkdir, readdir, rename, rm, stat as statPath } from "node:fs/promises";
23
23
  import { dirname, join } from "node:path";
24
24
 
25
25
  /** Cordis plugin name shown by the loader. */
@@ -35,6 +35,7 @@ export const HOST_VERSION = "0.10.1";
35
35
  export const TRASH_SESSION_PATH = "/ws-recency/session-trash";
36
36
  export const STATS_SESSION_PATH = "/ws-recency/session-stats";
37
37
  export const HEALTH_PATH = "/ws-recency/health";
38
+ export const MOVE_SESSION_PATH = "/ws-recency/session-move";
38
39
 
39
40
  const STOP_SETTLE_MS = 500;
40
41
  const MAX_BODY_BYTES = 16 * 1024;
@@ -438,6 +439,168 @@ export async function sessionStatistics(ctx, sessionId) {
438
439
  }
439
440
  }
440
441
 
442
+ /**
443
+ * Move one session into another workspace.
444
+ *
445
+ * A Session belongs to a Workspace through its stored header's `cwd`, and the
446
+ * Workspace registry refuses to attach a session whose header names another
447
+ * directory - so the header itself has to change. It is the immutably stored
448
+ * first record, so the move rewrites the log:
449
+ *
450
+ * 1. stop the session and read every event through the persistence seam
451
+ * 2. rename the old log directory into `.trash/ws-recency` (a backup, and it
452
+ * frees the id for the create below)
453
+ * 3. create the same id with the new `cwd` and append the same events
454
+ * 4. verify the new header, then detach from the old workspace and attach to
455
+ * the target (which validates the new cwd)
456
+ * 5. drop the projection cache so it rebuilds against the new path
457
+ *
458
+ * Any failure before step 4 rolls the backup back into place. The backup is kept
459
+ * afterwards on purpose: it is the only undo, and it is one rename away.
460
+ */
461
+ export async function moveSessionToWorkspace(ctx, sessionId, workspaceId) {
462
+ const stat = await ctx.sessionPersistence.stat(sessionId);
463
+ if (stat === undefined || stat === null) return { ok: false, error: "session not found" };
464
+ const header = stat.header;
465
+ if (header === undefined || header === null) return { ok: false, error: "session header unavailable" };
466
+
467
+ const workspaces = ctx.workspaceRegistry.list();
468
+ const target = workspaces.find((workspace) => workspace.id === workspaceId)
469
+ ?? workspaces.find((workspace) => workspace.path === workspaceId);
470
+ if (target === undefined) return { ok: false, error: "workspace not found" };
471
+ const targetPath = target.path;
472
+ if (header.cwd === targetPath) return { ok: false, error: "already in that workspace" };
473
+
474
+ let targetDirectory;
475
+ try {
476
+ targetDirectory = await statPath(targetPath);
477
+ } catch {
478
+ return { ok: false, error: `target directory does not exist: ${targetPath}` };
479
+ }
480
+ if (!targetDirectory.isDirectory()) return { ok: false, error: "target path is not a directory" };
481
+
482
+ try {
483
+ await ctx.parallel("workspace/session-stop", { sessionId });
484
+ } catch {
485
+ /* stopping is best effort, exactly like "stop and archive" */
486
+ }
487
+ await new Promise((resolve) => setTimeout(resolve, STOP_SETTLE_MS));
488
+
489
+ let events;
490
+ try {
491
+ events = await readSessionEvents(ctx, sessionId);
492
+ } catch (error) {
493
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
494
+ }
495
+
496
+ const located = typeof ctx.sessionPersistence.locate === "function" ? ctx.sessionPersistence.locate(header) : undefined;
497
+ const sourceDirectory = located !== undefined && located !== null && typeof located.path === "string" ? dirname(located.path) : undefined;
498
+ if (sourceDirectory === undefined) return { ok: false, error: "session log not found" };
499
+
500
+ const home = await dshHome();
501
+ const backupRoot = join(home, ".trash", "ws-recency");
502
+ const backup = join(backupRoot, `${sessionId}-move-${Date.now()}`);
503
+ let movedAside = false;
504
+ try {
505
+ await mkdir(backupRoot, { recursive: true });
506
+ await rename(sourceDirectory, backup);
507
+ movedAside = true;
508
+ } catch (error) {
509
+ return { ok: false, error: `could not park the old log: ${error instanceof Error ? error.message : String(error)}` };
510
+ }
511
+
512
+ /** Undo the rewrite: drop whatever was written and put the original back. */
513
+ const rollback = async () => {
514
+ try {
515
+ const partial = typeof ctx.sessionPersistence.locate === "function" ? ctx.sessionPersistence.locate({ ...header, cwd: targetPath }) : undefined;
516
+ if (partial !== undefined && partial !== null && typeof partial.path === "string") await rm(dirname(partial.path), { recursive: true, force: true });
517
+ } catch {
518
+ /* nothing partial to clean */
519
+ }
520
+ try {
521
+ await rename(backup, sourceDirectory);
522
+ } catch {
523
+ /* the backup stays in the trash folder */
524
+ }
525
+ };
526
+
527
+ try {
528
+ const created = await ctx.sessionPersistence.create({ ...header, cwd: targetPath }, { inheritedEventCount: 0 });
529
+ try {
530
+ if (events.length > 0) await created.append(events);
531
+ if (typeof created.flush === "function") await created.flush();
532
+ } finally {
533
+ try {
534
+ await created.close();
535
+ } catch {
536
+ /* closing is best effort */
537
+ }
538
+ }
539
+ const check = await ctx.sessionPersistence.stat(sessionId);
540
+ if (check === undefined || check === null || check.header === undefined || check.header.cwd !== targetPath) {
541
+ throw new Error("the rewritten header did not take");
542
+ }
543
+ } catch (error) {
544
+ await rollback();
545
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
546
+ }
547
+
548
+ /* Attach first, detach second: a refused attach must not orphan the session. */
549
+ try {
550
+ await target.attachSession(sessionId);
551
+ } catch (error) {
552
+ await rollback();
553
+ return { ok: false, error: `the workspace refused the attach: ${error instanceof Error ? error.message : String(error)}` };
554
+ }
555
+
556
+ const detached = [];
557
+ for (const workspace of workspaces) {
558
+ if (!Array.isArray(workspace.sessionIds) || !workspace.sessionIds.includes(sessionId)) continue;
559
+ if (workspace.id === target.id) continue;
560
+ try {
561
+ await workspace.detachSession(sessionId);
562
+ detached.push(workspace.id);
563
+ } catch {
564
+ /* the session is already in the target; a stale membership is cosmetic */
565
+ }
566
+ }
567
+
568
+ try {
569
+ const domain = ctx.get("storageDomain");
570
+ const projcache = typeof domain?.get === "function" ? domain.get("session_projcache") : undefined;
571
+ if (projcache !== undefined) await projcache.table("sessions").delete(sessionId);
572
+ } catch {
573
+ /* the cache is derived data */
574
+ }
575
+
576
+ return { ok: true, workspace: target.id, path: targetPath, detached, backup };
577
+ }
578
+
579
+ /** POST { sessionId, workspaceId } -> move that session to another workspace. */
580
+ export async function handleMoveRequest(ctx, req, res) {
581
+ if ((req.method ?? "POST").toUpperCase() !== "POST") {
582
+ sendJson(res, 405, { ok: false, error: "method not allowed" });
583
+ return;
584
+ }
585
+ if (!loopbackOrigin(req)) {
586
+ sendJson(res, 403, { ok: false, error: "origin not trusted" });
587
+ return;
588
+ }
589
+ try {
590
+ const body = await readJsonBody(req);
591
+ const sessionId = sessionIdFrom(req, body);
592
+ const workspaceId = body !== null && typeof body.workspaceId === "string" ? body.workspaceId.trim() : "";
593
+ if (sessionId === "" || workspaceId === "") {
594
+ sendJson(res, 400, { ok: false, error: "sessionId and workspaceId are required" });
595
+ return;
596
+ }
597
+ const result = await moveSessionToWorkspace(ctx, sessionId, workspaceId);
598
+ sendJson(res, result.ok ? 200 : 500, result);
599
+ } catch (error) {
600
+ sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
601
+ }
602
+ }
603
+
441
604
  /** POST { sessionId } -> token statistics for that session. */
442
605
  export async function handleStatsRequest(ctx, req, res) {
443
606
  if ((req.method ?? "POST").toUpperCase() !== "POST") {
@@ -483,7 +646,7 @@ export function apply(ctx) {
483
646
  handler: (req, res) => sendJson(res, 200, {
484
647
  ok: true,
485
648
  version: HOST_VERSION,
486
- routes: { stats: statsMounted, trash: trashMounted },
649
+ routes: { stats: statsMounted, trash: trashMounted, move: moveMounted },
487
650
  }),
488
651
  }), "ws-recency: health route");
489
652
 
@@ -496,6 +659,15 @@ export function apply(ctx) {
496
659
  }), "ws-recency: stats route");
497
660
  });
498
661
 
662
+ webCtx.inject(["sessionPersistence", "workspaceRegistry"], (moveCtx) => {
663
+ moveMounted = true;
664
+ webCtx.effect(() => server.register({
665
+ kind: "exact",
666
+ path: MOVE_SESSION_PATH,
667
+ handler: (req, res) => handleMoveRequest(moveCtx, req, res),
668
+ }), "ws-recency: move route");
669
+ });
670
+
499
671
  webCtx.inject(["sessionPersistence", "workspaceRegistry", "sessions"], (trashCtx) => {
500
672
  trashMounted = true;
501
673
  webCtx.effect(() => server.register({
@@ -512,3 +684,4 @@ export function apply(ctx) {
512
684
 
513
685
  let statsMounted = false;
514
686
  let trashMounted = false;
687
+ let moveMounted = false;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ws-recency",
3
- "version": "0.10.6",
4
- "description": "Sidebar session management for DeepSeek Harness: workspace groups sorted by latest activity, stale history folded, a global pinned section, follow-up marks, session delete into a recoverable trash, and per-session token statistics split by tool.",
3
+ "version": "0.11.1",
4
+ "description": "Sidebar session management for DeepSeek Harness: workspace groups sorted by latest activity, stale history folded, a global pinned section, follow-up marks, session delete into a recoverable trash, per-session token statistics split by tool, and moving a session to another workspace.",
5
5
  "license": "MIT",
6
6
  "author": "chyxhtc",
7
7
  "repository": {