dsh-ws-recency 0.11.3 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,7 +130,7 @@ dsh plugin --profile <profile> add github:chyxhtc/dsh-ws-recency
130
130
  或固定版本的预构建包:
131
131
 
132
132
  ```powershell
133
- dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.11.3/dsh-ws-recency-0.11.3.tgz
133
+ dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.11.5/dsh-ws-recency-0.11.5.tgz
134
134
  ```
135
135
 
136
136
  装完把 `dsh-ws-recency` 加进 `~/.hypers-work/profiles/<profile>/package.json` 的 `dsh.profile.bundles`,重启应用。
package/client.js CHANGED
@@ -213,6 +213,13 @@
213
213
  "move.working": "移动中…(会先停止会话)",
214
214
  "move.done": "已移动到「{workspace}」。若列表没刷新,切换一下工作区或重启应用。",
215
215
  "move.doneLive": "\n\n这个会话刚才是打开状态:建议重启应用后再继续对话,避免它沿用旧的目录。",
216
+ "move.repaired": "已补登记到「{workspace}」:上一次移动只搬了日志、没登记工作区,这次把它补上了。",
217
+ "adopt.title": "补登记游离会话",
218
+ "adopt.description": "有些会话的目录已经指向某个工作区,却不在任何分组里(移动中断会留下这种状态)。这里可以一次性找回。",
219
+ "adopt.action": "扫描",
220
+ "adopt.working": "扫描中…",
221
+ "adopt.done": "已补登记 {n} 个会话。",
222
+ "adopt.failed": "扫描失败:",
216
223
  "move.failed": "移动失败:",
217
224
  "stats.chart": "消耗分布",
218
225
  "stats.details.show": "显示明细 ▾",
@@ -267,6 +274,13 @@
267
274
  "move.working": "Moving\u2026 (the session is stopped first)",
268
275
  "move.done": "Moved to \"{workspace}\". If the list has not refreshed, switch workspaces or restart the app.",
269
276
  "move.doneLive": "\n\nThe session was open: restart the app before continuing it so it does not keep using the old directory.",
277
+ "move.repaired": "Registered in \"{workspace}\": the previous attempt moved the log without the bookkeeping, and this repaired it.",
278
+ "adopt.title": "Adopt unlisted sessions",
279
+ "adopt.description": "A session whose directory already belongs to a workspace can end up listed nowhere (an interrupted move leaves exactly this). Scan to re-attach them.",
280
+ "adopt.action": "Scan",
281
+ "adopt.working": "Scanning\u2026",
282
+ "adopt.done": "Re-attached {n} session(s).",
283
+ "adopt.failed": "Scan failed: ",
270
284
  "move.failed": "Move failed: ",
271
285
  "stats.chart": "Where the tokens went",
272
286
  "stats.details.show": "Show details",
@@ -1448,6 +1462,10 @@
1448
1462
  })
1449
1463
  .then(function (result) {
1450
1464
  if (result.json !== null && result.json.ok === true) {
1465
+ if (result.json.repaired === true) {
1466
+ body.textContent = t("move.repaired", { workspace: label });
1467
+ return;
1468
+ }
1451
1469
  body.textContent = t("move.done", { workspace: label });
1452
1470
  if (result.json.live === true) body.textContent += t("move.doneLive");
1453
1471
  return;
@@ -1561,6 +1579,44 @@
1561
1579
  );
1562
1580
  }
1563
1581
 
1582
+ /** Settings -> General: re-attach sessions that belong to no workspace. */
1583
+ function AdoptRow() {
1584
+ var result = react.useState("");
1585
+ var message = result[0];
1586
+ var setMessage = result[1];
1587
+ var run = function () {
1588
+ setMessage(t("adopt.working"));
1589
+ fetch("/ws-recency/session-adopt", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })
1590
+ .then(function (response) {
1591
+ return response.text().then(function (text) {
1592
+ var parsed = null;
1593
+ try { parsed = JSON.parse(text); } catch (error) { parsed = null; }
1594
+ return { status: response.status, text: text, json: parsed };
1595
+ });
1596
+ })
1597
+ .then(function (answer) {
1598
+ if (answer.json !== null && answer.json.ok === true) {
1599
+ var count = Array.isArray(answer.json.adopted) ? answer.json.adopted.length : 0;
1600
+ setMessage(t("adopt.done", { n: count }));
1601
+ return;
1602
+ }
1603
+ setMessage(t("adopt.failed") + (answer.json && answer.json.error ? answer.json.error : answer.text.slice(0, 120)));
1604
+ })
1605
+ .catch(function (error) {
1606
+ setMessage(t("adopt.failed") + (error && error.message ? error.message : error));
1607
+ });
1608
+ };
1609
+ return h("div", { className: "wsr-setRow" },
1610
+ h("div", { className: "wsr-setText" },
1611
+ h("div", { className: "wsr-setTitle" }, t("adopt.title")),
1612
+ h("div", { className: "wsr-setDesc" }, message === "" ? t("adopt.description") : message),
1613
+ ),
1614
+ h("div", { className: "wsr-controls" },
1615
+ h("button", { type: "button", className: "wsr-step", onClick: run }, t("adopt.action")),
1616
+ ),
1617
+ );
1618
+ }
1619
+
1564
1620
  /** Settings -> General row: the global pinned section on/off. */
1565
1621
  function PinSectionRow(props) {
1566
1622
  var settings = props.useStore(function (state) { return state; });
@@ -1699,6 +1755,16 @@
1699
1755
  }, ColorsRow);
1700
1756
  });
1701
1757
 
1758
+ ctx.slots.inject("settings.general.item", function () {
1759
+ return ctx.slots.register({
1760
+ name: "settings.general.item",
1761
+ id: "ws-recency-adopt",
1762
+ order: 49,
1763
+ store: prefs,
1764
+ locale: NS,
1765
+ }, AdoptRow);
1766
+ });
1767
+
1702
1768
  ctx.slots.inject("settings.general.item", function () {
1703
1769
  return ctx.slots.register({
1704
1770
  name: "settings.general.item",
package/index.js CHANGED
@@ -36,6 +36,7 @@ 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
38
  export const MOVE_SESSION_PATH = "/ws-recency/session-move";
39
+ export const ADOPT_SESSION_PATH = "/ws-recency/session-adopt";
39
40
 
40
41
  const STOP_SETTLE_MS = 500;
41
42
  const MAX_BODY_BYTES = 16 * 1024;
@@ -469,7 +470,28 @@ export async function moveSessionToWorkspace(ctx, sessionId, workspaceId) {
469
470
  ?? workspaces.find((workspace) => workspace.path === workspaceId);
470
471
  if (target === undefined) return { ok: false, error: "workspace not found" };
471
472
  const targetPath = target.path;
472
- if (header.cwd === targetPath) return { ok: false, error: "already in that workspace" };
473
+ if (header.cwd === targetPath) {
474
+ /* Already pointed at the target: a previous attempt moved the log but died
475
+ before the bookkeeping (the app's in-memory header refused it). Repair the
476
+ membership instead of failing, so such a session is not left unlisted. */
477
+ try {
478
+ await target.attachSession(sessionId);
479
+ } catch (error) {
480
+ return { ok: false, error: `session is already in that directory but the workspace refused it: ${error instanceof Error ? error.message : String(error)}` };
481
+ }
482
+ const stale = [];
483
+ for (const workspace of workspaces) {
484
+ if (!Array.isArray(workspace.sessionIds) || !workspace.sessionIds.includes(sessionId)) continue;
485
+ if (workspace.id === target.id) continue;
486
+ try {
487
+ await workspace.detachSession(sessionId);
488
+ stale.push(workspace.id);
489
+ } catch {
490
+ /* a stale membership that filters itself out anyway */
491
+ }
492
+ }
493
+ return { ok: true, repaired: true, workspace: target.id, path: targetPath, detached: stale };
494
+ }
473
495
 
474
496
  let targetDirectory;
475
497
  try {
@@ -550,9 +572,10 @@ export async function moveSessionToWorkspace(ctx, sessionId, workspaceId) {
550
572
  has the session open. The registry validates against its own view, so both the
551
573
  cached header and the live one have to follow - otherwise an open session is
552
574
  refused with its old directory. */
575
+ let wasLive = false;
576
+ try {
553
577
  const movedHeader = { ...header, cwd: targetPath };
554
578
  /* Cordis throws on an undeclared service, so the probe is guarded. */
555
- let wasLive = false;
556
579
  try {
557
580
  wasLive = typeof ctx.sessions?.get === "function" && ctx.sessions.get(sessionId) !== undefined;
558
581
  } catch {
@@ -583,6 +606,10 @@ export async function moveSessionToWorkspace(ctx, sessionId, workspaceId) {
583
606
  await rollback();
584
607
  return { ok: false, error: `the app still holds this session's old directory (${seenCwd}); restart the app and move it again` };
585
608
  }
609
+ } catch (error) {
610
+ await rollback();
611
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
612
+ }
586
613
 
587
614
  /* Attach first, detach second: a refused attach must not orphan the session. */
588
615
  try {
@@ -615,6 +642,51 @@ export async function moveSessionToWorkspace(ctx, sessionId, workspaceId) {
615
642
  return { ok: true, workspace: target.id, path: targetPath, detached, backup, live: wasLive };
616
643
  }
617
644
 
645
+ /**
646
+ * Re-attach sessions whose stored cwd already names a workspace but which are
647
+ * listed by none - the shape an aborted move leaves behind. Idempotent: it only
648
+ * ever adds memberships the registry's own validation accepts.
649
+ */
650
+ export async function adoptOrphanSessions(ctx) {
651
+ const snapshots = await ctx.sessionPersistence.list();
652
+ const workspaces = ctx.workspaceRegistry.list();
653
+ const byPath = new Map();
654
+ for (const workspace of workspaces) byPath.set(workspace.path, workspace);
655
+ const adopted = [];
656
+ for (const snapshot of snapshots) {
657
+ const header = snapshot !== null && snapshot !== undefined ? snapshot.header : undefined;
658
+ if (header === undefined || header === null || typeof header.cwd !== "string" || typeof header.id !== "string") continue;
659
+ const target = byPath.get(header.cwd);
660
+ if (target === undefined) continue;
661
+ const listed = workspaces.some((workspace) => Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(header.id));
662
+ if (listed) continue;
663
+ try {
664
+ await target.attachSession(header.id);
665
+ adopted.push(header.id);
666
+ } catch {
667
+ /* the registry refused it; leave it alone */
668
+ }
669
+ }
670
+ return { ok: true, adopted };
671
+ }
672
+
673
+ /** POST {} -> attach every session that belongs nowhere. */
674
+ export async function handleAdoptRequest(ctx, req, res) {
675
+ if ((req.method ?? "POST").toUpperCase() !== "POST") {
676
+ sendJson(res, 405, { ok: false, error: "method not allowed" });
677
+ return;
678
+ }
679
+ if (!loopbackOrigin(req)) {
680
+ sendJson(res, 403, { ok: false, error: "origin not trusted" });
681
+ return;
682
+ }
683
+ try {
684
+ sendJson(res, 200, await adoptOrphanSessions(ctx));
685
+ } catch (error) {
686
+ sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
687
+ }
688
+ }
689
+
618
690
  /** POST { sessionId, workspaceId } -> move that session to another workspace. */
619
691
  export async function handleMoveRequest(ctx, req, res) {
620
692
  if ((req.method ?? "POST").toUpperCase() !== "POST") {
@@ -705,6 +777,11 @@ export function apply(ctx) {
705
777
  path: MOVE_SESSION_PATH,
706
778
  handler: (req, res) => handleMoveRequest(moveCtx, req, res),
707
779
  }), "ws-recency: move route");
780
+ webCtx.effect(() => server.register({
781
+ kind: "exact",
782
+ path: ADOPT_SESSION_PATH,
783
+ handler: (req, res) => handleAdoptRequest(moveCtx, req, res),
784
+ }), "ws-recency: adopt route");
708
785
  });
709
786
 
710
787
  webCtx.inject(["sessionPersistence", "workspaceRegistry", "sessions"], (trashCtx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ws-recency",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
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",