dsh-ws-recency 0.10.5 → 0.11.0
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 +21 -3
- package/client.js +190 -3
- package/index.js +175 -2
- 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
|
|
|
@@ -68,13 +68,31 @@ DSH 原生的置顶只把会话顶到**它所属工作区分组的第一行**,
|
|
|
68
68
|
会话下方统计条上的「用量分析」,或会话「…」菜单 →「Token 消耗统计」:
|
|
69
69
|
|
|
70
70
|
- **真实账单**(每步记录的 usage):步数、输入(未缓存)、缓存命中、输出、合计;
|
|
71
|
-
- **消耗分布图**(按内容估算):主 agent 自己(思考 / 回复 / 用户消息 / 系统提示)vs
|
|
71
|
+
- **消耗分布图**(按内容估算):主 agent 自己(思考 / 回复 / 用户消息 / 系统提示)vs 每个工具,每个来源一条带颜色的横条,按占用长度直观对比;色相由工具身份决定(常用工具人工指定,其余按名字哈希,跨会话稳定),明艳度随占比排名递减;
|
|
72
72
|
- **明细表**:调用次数、参数估算、结果估算、合计、占比,按合计降序,默认折叠,点「显示明细」展开。
|
|
73
73
|
|
|
74
74
|
口径说明:prompt 的 token 是「整段上下文每一步都计费」,没法精确归属到某一条消息,所以「主 agent / 工具」的拆分是按内容长度估算(中文≈1 token/字,英文≈4 字符/token),用来比较占比;**总量是真的**。数据从会话日志算,跨重启也能看。大会话第一次打开约 1–2 秒(29MB 日志实测 1.2 秒),10 秒内重复打开走缓存。
|
|
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
|
+
**风险与退路**:这是插件里唯一改写会话存储的操作。备份会保留,回滚 = 把备份目录移回 `sessions/<project>/<id>`。
|
|
95
|
+
|
|
78
96
|
## 设置
|
|
79
97
|
|
|
80
98
|
**设置 → 通用**:
|
|
@@ -110,7 +128,7 @@ dsh plugin --profile <profile> add github:chyxhtc/dsh-ws-recency
|
|
|
110
128
|
或固定版本的预构建包:
|
|
111
129
|
|
|
112
130
|
```powershell
|
|
113
|
-
dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.
|
|
131
|
+
dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.11.0/dsh-ws-recency-0.11.0.tgz
|
|
114
132
|
```
|
|
115
133
|
|
|
116
134
|
装完把 `dsh-ws-recency` 加进 `~/.hypers-work/profiles/<profile>/package.json` 的 `dsh.profile.bundles`,重启应用。
|
package/client.js
CHANGED
|
@@ -123,6 +123,14 @@
|
|
|
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-list{display:flex;flex-direction:column;gap:4px}",
|
|
127
|
+
".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}",
|
|
128
|
+
".wsr-move-row:hover:not([disabled]){background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08))}",
|
|
129
|
+
".wsr-move-row[disabled]{opacity:.55;cursor:default}",
|
|
130
|
+
".wsr-move-text{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}",
|
|
131
|
+
".wsr-move-title{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
132
|
+
".wsr-move-path{font-size:11px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
133
|
+
".wsr-move-badge{font-size:11px;color:var(--dsw-alias-label-tertiary)}",
|
|
126
134
|
".wsr-chart{display:flex;flex-direction:column;gap:6px}",
|
|
127
135
|
".wsr-chart-title{font-size:11px;color:var(--dsw-alias-label-tertiary)}",
|
|
128
136
|
".wsr-chart-row{display:grid;grid-template-columns:120px 1fr 64px 56px;align-items:center;gap:8px}",
|
|
@@ -195,6 +203,14 @@
|
|
|
195
203
|
"stats.agentDetail": "主 agent 构成:思考 {reasoning} · 回复 {text} · 用户消息 {user} · 系统提示 {system}",
|
|
196
204
|
"stats.diagnose.unmounted": "\n\n(宿主半已加载,但统计路由没挂上——重启应用再试)",
|
|
197
205
|
"stats.diagnose.noHost": "\n\n(宿主半未加载:需要重启应用加载新版本)",
|
|
206
|
+
"menu.move": "移动到工作区…",
|
|
207
|
+
"move.title": "移动到工作区",
|
|
208
|
+
"move.note": "会话属于哪个工作区,由它创建时的目录决定;移动会改写会话记录的 cwd,把日志搬到新工作区目录,旧日志备份在 .trash/ws-recency。",
|
|
209
|
+
"move.confirm": "把会话移动到「{workspace}」?",
|
|
210
|
+
"move.current": "当前",
|
|
211
|
+
"move.working": "移动中…(会先停止会话)",
|
|
212
|
+
"move.done": "已移动到「{workspace}」。若列表没刷新,切换一下工作区或重启应用。",
|
|
213
|
+
"move.failed": "移动失败:",
|
|
198
214
|
"stats.chart": "消耗分布",
|
|
199
215
|
"stats.details.show": "显示明细 ▾",
|
|
200
216
|
"stats.details.hide": "收起明细 ▴",
|
|
@@ -239,6 +255,14 @@
|
|
|
239
255
|
"stats.agentDetail": "Agent composition: reasoning {reasoning} \u00b7 reply {text} \u00b7 user messages {user} \u00b7 system prompt {system}",
|
|
240
256
|
"stats.diagnose.unmounted": "\n\n(the host half is loaded but the statistics route did not mount - restart the app and retry)",
|
|
241
257
|
"stats.diagnose.noHost": "\n\n(the host half is not loaded: restart the app to pick up the new version)",
|
|
258
|
+
"menu.move": "Move to workspace\u2026",
|
|
259
|
+
"move.title": "Move to workspace",
|
|
260
|
+
"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.",
|
|
261
|
+
"move.confirm": "Move this session to \"{workspace}\"?",
|
|
262
|
+
"move.current": "current",
|
|
263
|
+
"move.working": "Moving\u2026 (the session is stopped first)",
|
|
264
|
+
"move.done": "Moved to \"{workspace}\". If the list has not refreshed, switch workspaces or restart the app.",
|
|
265
|
+
"move.failed": "Move failed: ",
|
|
242
266
|
"stats.chart": "Where the tokens went",
|
|
243
267
|
"stats.details.show": "Show details",
|
|
244
268
|
"stats.details.hide": "Hide details",
|
|
@@ -1138,6 +1162,57 @@
|
|
|
1138
1162
|
return row;
|
|
1139
1163
|
}
|
|
1140
1164
|
|
|
1165
|
+
/**
|
|
1166
|
+
* Colour identity for a source.
|
|
1167
|
+
*
|
|
1168
|
+
* The tool set is open - every plugin and MCP server adds tools - so it
|
|
1169
|
+
* cannot be enumerated. A curated hue for the common tools plus a stable
|
|
1170
|
+
* hash for everything else keeps one name on one hue across sessions, and
|
|
1171
|
+
* the rank only modulates how vivid it is: the heaviest source reads
|
|
1172
|
+
* strongest, the tail fades back.
|
|
1173
|
+
*/
|
|
1174
|
+
var TOOL_HUES = {
|
|
1175
|
+
pwsh: 32, powershell: 32, shell: 32, bash: 32, command: 32,
|
|
1176
|
+
read: 152, write: 217, edit: 268, grep: 190, glob: 88,
|
|
1177
|
+
todo_write: 292, todo_read: 292, todo: 292,
|
|
1178
|
+
job_output: 330, job_list: 330, job_kill: 330,
|
|
1179
|
+
web_fetch: 205, web_search: 205, platform_search: 205, multi_search: 205, advanced_search: 205,
|
|
1180
|
+
subagent: 250, subagent_fork: 250, workflow: 250, spawn_teammate: 250, send_message: 250, wait_agent: 250,
|
|
1181
|
+
ask_user_question: 12, present: 12,
|
|
1182
|
+
skill: 60, create_goal: 60, update_goal: 60,
|
|
1183
|
+
};
|
|
1184
|
+
var TOOL_PREFIX_HUES = [
|
|
1185
|
+
["mcp__", 235],
|
|
1186
|
+
["browser_", 0],
|
|
1187
|
+
["univer_", 175],
|
|
1188
|
+
["weknora_", 300],
|
|
1189
|
+
["job_", 330],
|
|
1190
|
+
["team_", 250],
|
|
1191
|
+
];
|
|
1192
|
+
|
|
1193
|
+
function hashHue(name) {
|
|
1194
|
+
var hash = 0;
|
|
1195
|
+
for (var i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) % 360;
|
|
1196
|
+
return hash;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function sourceHue(name) {
|
|
1200
|
+
if (typeof name !== "string" || name === "") return 210;
|
|
1201
|
+
var key = name.toLowerCase();
|
|
1202
|
+
if (TOOL_HUES[key] !== undefined) return TOOL_HUES[key];
|
|
1203
|
+
for (var i = 0; i < TOOL_PREFIX_HUES.length; i += 1) {
|
|
1204
|
+
if (key.indexOf(TOOL_PREFIX_HUES[i][0]) === 0) return TOOL_PREFIX_HUES[i][1];
|
|
1205
|
+
}
|
|
1206
|
+
return hashHue(key);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** Rank 0 is the heaviest source; later ranks fade towards grey. */
|
|
1210
|
+
function sourceColor(hue, rank) {
|
|
1211
|
+
var saturation = Math.max(40, 88 - rank * 8);
|
|
1212
|
+
var lightness = Math.min(64, 50 + rank * 2);
|
|
1213
|
+
return "hsl(" + hue + ", " + saturation + "%, " + lightness + "%)";
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1141
1216
|
function renderStats(body, stats) {
|
|
1142
1217
|
body.textContent = "";
|
|
1143
1218
|
while (body.firstChild !== null) body.removeChild(body.firstChild);
|
|
@@ -1163,10 +1238,14 @@
|
|
|
1163
1238
|
body.appendChild(grid);
|
|
1164
1239
|
|
|
1165
1240
|
var tools = estimate.tools || [];
|
|
1166
|
-
var
|
|
1167
|
-
var entries = [{ name: t("stats.agent"), value: agent.total || 0, share: agent.share || 0, color: "#4b8bf5" }];
|
|
1241
|
+
var entries = [{ name: t("stats.agent"), value: agent.total || 0, share: agent.share || 0, color: sourceColor(217, 0) }];
|
|
1168
1242
|
for (var j = 0; j < tools.length; j += 1) {
|
|
1169
|
-
entries.push({
|
|
1243
|
+
entries.push({
|
|
1244
|
+
name: tools[j].name,
|
|
1245
|
+
value: tools[j].total || 0,
|
|
1246
|
+
share: tools[j].share || 0,
|
|
1247
|
+
color: sourceColor(sourceHue(tools[j].name), j + 1),
|
|
1248
|
+
});
|
|
1170
1249
|
}
|
|
1171
1250
|
|
|
1172
1251
|
/* The picture first: one bar per source, sized against the largest. */
|
|
@@ -1299,6 +1378,104 @@
|
|
|
1299
1378
|
});
|
|
1300
1379
|
}
|
|
1301
1380
|
|
|
1381
|
+
/** Which workspace currently owns a session, per the client snapshot. */
|
|
1382
|
+
function workspaceOfSession(sessionId) {
|
|
1383
|
+
var items = (data.workspaces && data.workspaces.items) || [];
|
|
1384
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
1385
|
+
if ((items[i].sessionIds || []).indexOf(sessionId) !== -1) return items[i];
|
|
1386
|
+
}
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** Modal that lists the workspaces and moves the session into the chosen one. */
|
|
1391
|
+
function openMovePanel(sessionId) {
|
|
1392
|
+
if (typeof document === "undefined") return;
|
|
1393
|
+
closeStatsPanel();
|
|
1394
|
+
var backdrop = statsDiv("wsr-modal-backdrop");
|
|
1395
|
+
var panel = statsDiv("wsr-modal");
|
|
1396
|
+
backdrop.appendChild(panel);
|
|
1397
|
+
var head = statsDiv("wsr-modal-head");
|
|
1398
|
+
head.appendChild(statsDiv("wsr-modal-title", t("move.title")));
|
|
1399
|
+
var close = document.createElement("button");
|
|
1400
|
+
close.type = "button";
|
|
1401
|
+
close.className = "wsr-modal-close";
|
|
1402
|
+
close.textContent = "\u00d7";
|
|
1403
|
+
close.addEventListener("click", closeStatsPanel);
|
|
1404
|
+
head.appendChild(close);
|
|
1405
|
+
panel.appendChild(head);
|
|
1406
|
+
var body = statsDiv("wsr-modal-body");
|
|
1407
|
+
panel.appendChild(body);
|
|
1408
|
+
body.appendChild(statsDiv("wsr-stats-note", t("move.note")));
|
|
1409
|
+
|
|
1410
|
+
var current = workspaceOfSession(sessionId);
|
|
1411
|
+
var items = (data.workspaces && data.workspaces.items) || [];
|
|
1412
|
+
var list = statsDiv("wsr-move-list");
|
|
1413
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
1414
|
+
(function (item) {
|
|
1415
|
+
var isCurrent = current !== null && current.workspaceId === item.workspaceId;
|
|
1416
|
+
var row = document.createElement("button");
|
|
1417
|
+
row.type = "button";
|
|
1418
|
+
row.className = "wsr-move-row";
|
|
1419
|
+
row.setAttribute("data-wsr-move-target", String(item.workspaceId));
|
|
1420
|
+
if (isCurrent) row.setAttribute("disabled", "true");
|
|
1421
|
+
var text = statsDiv("wsr-move-text");
|
|
1422
|
+
text.appendChild(statsDiv("wsr-move-title", (item.title && String(item.title)) || baseName(item.path)));
|
|
1423
|
+
text.appendChild(statsDiv("wsr-move-path", String(item.path || "")));
|
|
1424
|
+
row.appendChild(text);
|
|
1425
|
+
row.appendChild(statsDiv("wsr-move-badge", isCurrent ? t("move.current") : ""));
|
|
1426
|
+
if (!isCurrent) {
|
|
1427
|
+
row.addEventListener("click", function () {
|
|
1428
|
+
var label = (item.title && String(item.title)) || baseName(item.path);
|
|
1429
|
+
if (typeof window.confirm !== "function" || !window.confirm(t("move.confirm", { workspace: label }))) return;
|
|
1430
|
+
body.textContent = t("move.working");
|
|
1431
|
+
fetch("/ws-recency/session-move?sessionId=" + encodeURIComponent(sessionId), {
|
|
1432
|
+
method: "POST",
|
|
1433
|
+
headers: { "content-type": "application/json" },
|
|
1434
|
+
body: JSON.stringify({ sessionId: sessionId, workspaceId: item.workspaceId }),
|
|
1435
|
+
})
|
|
1436
|
+
.then(function (response) {
|
|
1437
|
+
return response.text().then(function (text) {
|
|
1438
|
+
var parsed = null;
|
|
1439
|
+
try { parsed = JSON.parse(text); } catch (error) { parsed = null; }
|
|
1440
|
+
return { status: response.status, text: text, json: parsed };
|
|
1441
|
+
});
|
|
1442
|
+
})
|
|
1443
|
+
.then(function (result) {
|
|
1444
|
+
if (result.json !== null && result.json.ok === true) {
|
|
1445
|
+
body.textContent = t("move.done", { workspace: label });
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
var reason = (result.json !== null && result.json.error) || (result.text || "").trim().slice(0, 160) || "(empty response)";
|
|
1449
|
+
body.textContent = t("move.failed") + "HTTP " + result.status + " \u00b7 " + reason;
|
|
1450
|
+
})
|
|
1451
|
+
.catch(function (error) {
|
|
1452
|
+
body.textContent = t("move.failed") + (error && error.message ? error.message : error);
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
list.appendChild(row);
|
|
1457
|
+
})(items[i]);
|
|
1458
|
+
}
|
|
1459
|
+
body.appendChild(list);
|
|
1460
|
+
backdrop.addEventListener("click", function (event) {
|
|
1461
|
+
if (event !== undefined && event.target === backdrop) closeStatsPanel();
|
|
1462
|
+
});
|
|
1463
|
+
document.body.appendChild(backdrop);
|
|
1464
|
+
statsModal = backdrop;
|
|
1465
|
+
document.addEventListener("keydown", onStatsKey);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/** Session "..." menu row: move the conversation to another workspace. */
|
|
1469
|
+
function MoveMenuItem(props) {
|
|
1470
|
+
var Item = ui.MenuItemButton;
|
|
1471
|
+
if (typeof Item !== "function") return null;
|
|
1472
|
+
var Icon = ui.IconFolderOpenOutlineRegular;
|
|
1473
|
+
return h(Item, {
|
|
1474
|
+
icon: typeof Icon === "function" ? h(Icon, {}) : undefined,
|
|
1475
|
+
onSelect: function () { openMovePanel(props.sessionId); },
|
|
1476
|
+
}, t("menu.move"));
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1302
1479
|
/** Session "..." menu row: token statistics of that session. */
|
|
1303
1480
|
function StatsMenuItem(props) {
|
|
1304
1481
|
var Item = ui.MenuItemButton;
|
|
@@ -1545,6 +1722,16 @@
|
|
|
1545
1722
|
}, FollowUpDot);
|
|
1546
1723
|
});
|
|
1547
1724
|
|
|
1725
|
+
ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
|
|
1726
|
+
return ctx.slots.register({
|
|
1727
|
+
name: "sidebar.workspaces.session.menu.item",
|
|
1728
|
+
id: "ws-recency-move",
|
|
1729
|
+
order: 690,
|
|
1730
|
+
store: prefs,
|
|
1731
|
+
locale: NS,
|
|
1732
|
+
}, MoveMenuItem);
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1548
1735
|
ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
|
|
1549
1736
|
return ctx.slots.register({
|
|
1550
1737
|
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.
|
|
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,
|
|
3
|
+
"version": "0.11.0",
|
|
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": {
|