dsh-claude-style 0.9.0 → 0.9.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to `dsh-claude-style` are documented here, newest first.
4
4
 
5
+ ## [0.9.1] - 2026-09-27
6
+
7
+ [中文](#cn-0.9.1) | [English](#en-0.9.1)
8
+
9
+ <h3 id="cn-0.9.1">问题修复</h3>
10
+
11
+ - 修复 **已归档列表里的会话删不掉**:点「删除」后宿主半边只删存储目录、不动归档记录,存储目录已经不在的会话更是直接被拒——列表那一行永远留着,重载页面后还在。现在删除成功时宿主半边在同一请求里把该会话从归档集合里移除;归档集合里存储目录已消失的条目(幽灵)也按删除成功处理并移出归档集合,浏览器随即重拉一次会话列表基线,已删除的会话不会再靠旧摘要顶在已归档列表或会话树里。旧版宿主半边对目录缺失的会话回 404 时,浏览器同样移除该行。
12
+
13
+ <h3 id="en-0.9.1">Bug Fixes</h3>
14
+
15
+ - **Fixed archived sessions refusing to disappear when deleted**: the delete button removed the stored directory while the archive record kept the id, and a session whose directory was already gone was refused outright — the row stayed on the list and survived a page reload. A successful delete now also drops the session from the archive set in the same request, an archive entry whose stored directory has vanished is answered as deleted and leaves the set, and the browser re-pulls the session baseline right after, so a deleted session no longer lingers on the archived list or in the session tree on a stale summary. Against an older host half that still answers 404 for a missing directory, the browser removes the row just the same.
16
+
17
+ **Full Changelog**: [v0.9.0...v0.9.1](https://github.com/Nwflower/dsh-claude-style/compare/v0.9.0...v0.9.1)
18
+
5
19
  ## [0.9.0] - 2026-09-26
6
20
 
7
21
  [中文](#cn-0.9.0) | [English](#en-0.9.0)
@@ -99,7 +99,7 @@
99
99
 
100
100
  - **决定**:
101
101
  - 宿主半边的私有路由(`/username`、`/usage`、`/session-search`、`/hdsl`、`/hdsl-skin.png`、`POST /session-delete`)处理前先调 `connection.requestRejection(req)`,拒绝即回 401/403;宿主没有该服务时,插件自带的替身只放行回环请求(回环 Host、无跨站标记、Origin 与 Host 一致)。模型文案与字体是公开的静态资产。
102
- - 删除会话另加四道:只接受 POST;id 必须符合宿主的会话 id 格式;正在打开的会话拒绝;目录解析后必须留在会话根目录内。删除发生在宿主半边,浏览器只提交一个 id。
102
+ - 删除会话另加四道:只接受 POST;id 必须符合宿主的会话 id 格式;正在打开的会话拒绝;目录解析后必须留在会话根目录内。删除发生在宿主半边,浏览器只提交一个 id。删除成功时宿主半边同时经 `workspaceRegistry.unarchiveSession` 把该 id 从归档集合里移除;归档集合里存储目录已消失的条目(幽灵)同样按删除成功应答并移出归档集合,否则归档列表会一直留着这行。会话根目录读不了是故障,按 500 应答。
103
103
  - 用量汇总只读:第三方插件的账本只读不写,自己的缓存放在 `$DSH_HOME/cache/dsh-claude-style/`。
104
104
  - 客户端:来自设置、账号服务、系统或其他插件的字符串只用 `textContent` 或元素属性写入;图标复制节点,不重新解析标记。
105
105
  - **理由**:插件路由不在宿主自己的栅栏之内;`dsh web` 绑定 `0.0.0.0` 时局域网可直接访问,而页面能驱动执行 shell 命令的智能体。
@@ -195,7 +195,7 @@
195
195
 
196
196
  ## D21. 已归档列表跟随宿主的两份客户端数据
197
197
 
198
- - **决定**:侧栏「已归档」是插件自己的平铺列表,订阅宿主 `workspaces.list`(归档集合)与 `sessions.list`(标题、时间、来源),两者都就绪后按宿主「仅已归档」的规则重算:只收已有会话摘要、非子代理、非空白占位的归档会话,按更新时间排序,内容不变时不重建。宿主自带的筛选状态存在 ui-workspace 私有的视图 store 里,不是客户端服务,只能靠点它的菜单改,因此不用。取消归档走 `workspaces.unarchiveSession`;删除走宿主半边的私有路由(D11),失败记录警告。点击已归档行显示宿主的同一条提示:用宿主的 Toast 组件与 `workspace` 文案表里的 `toast.archivedNotOpenable`(宿主的提示通道是私有的)。
198
+ - **决定**:侧栏「已归档」是插件自己的平铺列表,订阅宿主 `workspaces.list`(归档集合)与 `sessions.list`(标题、时间、来源),两者都就绪后按宿主「仅已归档」的规则重算:只收已有会话摘要、非子代理、非空白占位的归档会话,按更新时间排序,内容不变时不重建。宿主自带的筛选状态存在 ui-workspace 私有的视图 store 里,不是客户端服务,只能靠点它的菜单改,因此不用。取消归档走 `workspaces.unarchiveSession`;删除走宿主半边的私有路由(D11),宿主半边在删除目录的同时把 id 移出归档集合,浏览器删除成功后重拉一次会话列表基线,让已删除会话的旧摘要立刻离开投影、不再顶着会话树里的行;其余失败记录警告。点击已归档行显示宿主的同一条提示:用宿主的 Toast 组件与 `workspace` 文案表里的 `toast.archivedNotOpenable`(宿主的提示通道是私有的)。
199
199
  - **代价**:宿主「仅已归档」的规则变化时需要同步。
200
200
  - **重审条件**:宿主把视图筛选或提示通道开放为客户端服务时改用宿主的。
201
201
 
package/host/index.js CHANGED
@@ -91,7 +91,10 @@ const HDSL_LAYERS = ['process', 'user-env']
91
91
  * controller archives and unarchives, and the agent protocol's session delete is
92
92
  * the host delegating to an ACP agent that owns the storage. The archived row's
93
93
  * delete button therefore comes here, where the stored session directory under
94
- * the harness home can be removed. The path is also spelled in
94
+ * the harness home is removed and the id is dropped from the workspace
95
+ * registry's archive set — the stored-directory miss included, so an archive
96
+ * entry whose storage is already gone leaves the set instead of pinning its
97
+ * row to the list. The path is also spelled in
95
98
  * src/constants.js (SESSION_DELETE_ROUTE) for the browser half; keep the two in
96
99
  * step.
97
100
  */
@@ -451,13 +454,37 @@ function readRequestBody(req, limit) {
451
454
  })
452
455
  }
453
456
 
457
+ /**
458
+ * Drop one id from the workspace registry's archive set, durably.
459
+ *
460
+ * The registry is read at request time: it mounts with the workspace domain and
461
+ * can complete after this plugin. A host without one has no archive set to
462
+ * update — and no archived list to delete from — while a failing unarchive
463
+ * propagates: the route answers 500, the row stays, and the next delete
464
+ * retries through the miss branch.
465
+ */
466
+ async function unarchiveSession(ctx, sessionId) {
467
+ const registry = ctx.get?.('workspaceRegistry')
468
+ if (typeof registry?.unarchiveSession !== 'function') return
469
+ await registry.unarchiveSession(sessionId)
470
+ }
471
+
454
472
  /**
455
473
  * Delete one stored session: the directory named by the id under one of the
456
- * sessions root's working-directory directories.
474
+ * sessions root's working-directory directories, and the id's entry in the
475
+ * workspace registry's archive set.
457
476
  *
458
477
  * The id never reaches a path unchecked — it must match the harness's own
459
478
  * shape, the lookup is by exact name, and the resolved directory must stay
460
479
  * inside the sessions root.
480
+ *
481
+ * The archive set can name a session whose stored directory is already gone —
482
+ * an earlier removal took the directory while the registry entry stayed, and
483
+ * the stale session summary keeps its row on the archived list. The miss
484
+ * answers success too: the registry's unarchive runs no existence check and
485
+ * resolves without writing for an id that is not archived, so the entry leaves
486
+ * the set either way and the row is gone for good instead of coming back on
487
+ * the next reload.
461
488
  */
462
489
  async function deleteSession(ctx, req, res) {
463
490
  const refused = refusalOf(ctx, req)
@@ -483,34 +510,41 @@ async function deleteSession(ctx, req, res) {
483
510
  }
484
511
  const root = resolve(sessionsRoot(ctx))
485
512
  let dir = null
486
- try {
487
- for (const entry of readdirSync(root)) {
488
- const candidate = join(root, entry, sessionId)
489
- let stat
490
- try {
491
- stat = statSync(candidate)
492
- } catch {
493
- continue
494
- }
495
- if (stat.isDirectory()) {
496
- dir = candidate
497
- break
498
- }
513
+ // The root listing is the storage's own answer, so a failure there is a
514
+ // fault and propagates to the route's 500 answer; a candidate that stats as
515
+ // absent is the OS's "not under this entry" — the expected state the scan
516
+ // moves past.
517
+ for (const entry of readdirSync(root)) {
518
+ const candidate = join(root, entry, sessionId)
519
+ let stat
520
+ try {
521
+ stat = statSync(candidate)
522
+ } catch {
523
+ continue
499
524
  }
500
- } catch {
501
- dir = null
525
+ if (stat.isDirectory()) {
526
+ dir = candidate
527
+ break
528
+ }
529
+ }
530
+ if (dir === null) {
531
+ await unarchiveSession(ctx, sessionId)
532
+ sendJson(res, 200, { ok: true, ghost: true })
533
+ return
502
534
  }
503
- if (dir === null || !resolve(dir).startsWith(root + sep)) {
504
- sendJson(res, 404, { ok: false, error: 'session not found' })
535
+ if (!resolve(dir).startsWith(root + sep)) {
536
+ sendJson(res, 403, { ok: false, error: 'session directory outside the sessions root' })
505
537
  return
506
538
  }
507
539
  try {
508
540
  rmSync(dir, { recursive: true, force: true })
509
541
  } catch (error) {
542
+ // A storage fault answers 500 with the OS error — nothing is swallowed.
510
543
  sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
511
544
  return
512
545
  }
513
- sendJson(res, 200, { ok: true })
546
+ await unarchiveSession(ctx, sessionId)
547
+ sendJson(res, 200, { ok: true, ghost: false })
514
548
  }
515
549
 
516
550
  /**
package/lib/client.js CHANGED
@@ -363,8 +363,8 @@ window.__ModuleLoader__.load({
363
363
  * The host half's session-deletion route (host/index.js, SESSION_DELETE_PATH).
364
364
  * The harness gives the browser half no deletion API of its own, so the
365
365
  * archived row's delete button posts the session id here and the host half
366
- * removes the stored session directory. Keep the path in step with the host
367
- * half.
366
+ * removes the stored session directory and the id's entry in the workspace
367
+ * registry's archive set. Keep the path in step with the host half.
368
368
  */
369
369
  const SESSION_DELETE_ROUTE = '/dsh-claude-style/session-delete'
370
370
  /**
@@ -6773,7 +6773,7 @@ window.__ModuleLoader__.load({
6773
6773
  // ============================================================================
6774
6774
  // 构建编号(由 scripts/build.mjs 按产物内容生成) (Build id)
6775
6775
  // ============================================================================
6776
- var BUILD_ID = '64a08929e6f1'
6776
+ var BUILD_ID = 'ba446e3d486b'
6777
6777
 
6778
6778
  /**
6779
6779
  * The host's access-mode trigger: the button its permission slot renders.
@@ -14393,7 +14393,7 @@ window.__ModuleLoader__.load({
14393
14393
  let items = null
14394
14394
  /** Ids, titles and times of the rows on screen, joined; a list tick that changes none of them leaves the rows alone. */
14395
14395
  let renderedKey
14396
- /** Rows deleted through the host half: the archive set keeps their ids until the host forgets them. */
14396
+ /** Rows deleted through the host half, hidden while the host's two lists catch up. */
14397
14397
  const deletedIds = {}
14398
14398
  /**
14399
14399
  * The host's workspace and session services, once both are reachable;
@@ -14527,9 +14527,14 @@ window.__ModuleLoader__.load({
14527
14527
  * The harness gives the browser half no deletion API of its own (the
14528
14528
  * workspace controller archives and unarchives; the agent protocol's
14529
14529
  * session delete is the host delegating to an ACP agent that owns the
14530
- * storage), so the skin's host half removes the session's stored directory
14531
- * and answers here. The row stays on a refusal — the host refuses a live
14532
- * session — and the refusal is logged the way an unarchive refusal is.
14530
+ * storage), so the skin's host half removes the session's stored
14531
+ * directory and drops the id from the registry's archive set before it
14532
+ * answers. A stored-directory miss answers success too — an archive
14533
+ * entry whose directory is already gone is the ghost this buries — and
14534
+ * a 404 that still carries the host's own "session not found" JSON is
14535
+ * the same miss from an older host half without that branch: the storage
14536
+ * is gone all the same, so the row leaves too. Every other refusal keeps
14537
+ * the row, logged the way an unarchive refusal is.
14533
14538
  */
14534
14539
  function removeArchived(id) {
14535
14540
  fetch(SESSION_DELETE_ROUTE, {
@@ -14537,15 +14542,36 @@ window.__ModuleLoader__.load({
14537
14542
  credentials: 'same-origin',
14538
14543
  headers: { 'content-type': 'application/json' },
14539
14544
  body: JSON.stringify({ sessionId: id }),
14540
- }).then(response => response.json()).then(result => {
14541
- if (result.ok !== true) throw new Error(result.error || 'refused')
14542
- deletedIds[id] = true
14543
- refreshItems()
14545
+ }).then(response => response.json().then(result => ({ status: response.status, result }))).then(({ status, result }) => {
14546
+ if (result?.ok === true || (status === 404 && result?.error === 'session not found')) {
14547
+ deletedIds[id] = true
14548
+ refreshItems()
14549
+ refreshSessions()
14550
+ return
14551
+ }
14552
+ throw new Error(result?.error || `HTTP ${status}`)
14544
14553
  }).catch(reason => {
14545
14554
  console.warn('dsh-claude-style: session delete rejected:', reason)
14546
14555
  })
14547
14556
  }
14548
14557
 
14558
+ /**
14559
+ * Re-pull the host's session baseline right after a delete.
14560
+ *
14561
+ * The full list is what the projection prunes by: the host's fresh list
14562
+ * answer no longer carries the removed id, so its stale summary — the
14563
+ * one that kept a deleted session looking alive — leaves `sessions.list`
14564
+ * at this pull instead of at the next reconnect, and the host's own tree
14565
+ * drops the row with it. The pull is the sessions service's single-flight
14566
+ * refresh, so a pull already under way is reused.
14567
+ */
14568
+ function refreshSessions() {
14569
+ if (typeof sessions?.refresh !== 'function') return
14570
+ sessions.refresh().catch(reason => {
14571
+ console.warn('dsh-claude-style: session baseline refresh rejected:', reason)
14572
+ })
14573
+ }
14574
+
14549
14575
  /**
14550
14576
  * Put a conversation back among the live ones. The row leaves this list
14551
14577
  * on the archive set's next tick; a refusal leaves it in place.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-claude-style",
3
3
  "description": "Claude Code Desktop theme for DeepSeek Harness (dsh) web GUI — visual & interaction overhaul",
4
- "version": "0.9.0",
4
+ "version": "0.9.1",
5
5
  "type": "module",
6
6
  "main": "host/index.js",
7
7
  "icon": "lib/claude-mark.svg",