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 +1 -1
- package/client.js +66 -0
- package/index.js +79 -2
- package/package.json +1 -1
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.
|
|
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)
|
|
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
|
+
"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",
|