dsh-claude-style 0.6.0 → 0.6.4

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,42 @@
2
2
 
3
3
  All notable changes to `dsh-claude-style` are documented here, newest first.
4
4
 
5
+ ## [0.6.4] - 2026-09-24
6
+
7
+ [中文](#cn-0.6.4) | [English](#en-0.6.4)
8
+
9
+ <h3 id="cn-0.6.4">问题修复</h3>
10
+
11
+ - 修复 **刚启动、账号抽屉还没展开过时,页脚里的插件条目一直留在账户行旁边**:隐藏页脚条目与把条目镜像进抽屉原本挤在同一次调用里,而抽屉没打开时这次调用会提前返回,于是宿主重新渲染出来的插件图标继续画在侧栏底部、紧挨账户行,直到抽屉被展开过一次才被标记隐藏——这正是该现象只在首次启动后出现的原因。现在隐藏条目每次同步都跑,只有镜像需要抽屉容器。
12
+ - 修复 **底栏统计弹层偶尔只剩一段(只显示会话统计,或只显示 Token 用量)**:皮肤按标签文字区分宿主的两颗药丸,读取窗口又只有 300 毫秒,而宿主的两个面板按它自己的提交节奏挂载,晚到一步就丢掉那一段。现在按面板自身的标记判定类型,等待拉长到 800 毫秒、读失败时重试一次;药丸按钮每一步都从 DOM 重新取,窗口内还会再按一次——宿主重渲染换掉节点时,按旧节点等于没按;首次只读到一段时安排一次补读把缺的那段补上,卡片只增不减,已经打开的卡片不会被一次失败的读取改小。会话本身没有计时数据时宿主也不渲染计时面板,此时只有 Token 用量是正确的。
13
+ - 修复 **0.1.7-rc.2 里工作区会话行的状态圈消失**:新宿主把会话行的前导座位交给槽位出口渲染,座位里因此始终有一个 `div[data-slot]` 包裹层(`display: contents`),座位不再是空元素,画在 `:empty` 上的圆圈就不再出现。圆圈现在也挂在空的槽位出口上;座位里带运行状态点时仍由状态点自己绘制。
14
+ - 修复 **账户抽屉收起时它的行与图标停在账户行上方**:自建抽屉的内容要在收起状态下预先对账(镜像行只在收起时同步、展开时冻结),所以收起时面板连同齿轮图标一直挂在账户行正上方,此前只用透明度隐藏,内容仍留在绘制与命中树里。收起状态改用 `visibility` 隐藏(保留布局,关闭的淡出照常),展开时恢复可见。
15
+
16
+ <h3 id="en-0.6.4">Bug Fixes</h3>
17
+
18
+ - Fix **the footer's plugin entries staying beside the account row after a fresh start until the drawer has been opened once**: hiding the entries and mirroring them into the drawer shared one call that returned early while no drawer was up, so the plugin icons the host re-rendered stayed painted at the sidebar's bottom next to the account row until the drawer had been opened once — which is why the symptom only appeared after a fresh start. The hiding now runs on every sync; only the mirroring needs the drawer's container.
19
+ - Fix **the stats card occasionally showing only one section (session statistics alone, or Token usage alone)**: the skin told the host's two pills apart by their label text and read with a 300 ms window, while the host mounts its panels on its own commit — one late panel lost that section. The read now identifies each panel by its own marker, waits up to 800 ms and retries once; it re-resolves the pill from the DOM at every step and presses the live node again while the window lasts, because a press on a node the host has since re-rendered goes nowhere; and a card that did come up short schedules one late re-read that fills the missing section in, so the card only ever grows and a failed read never shrinks a card already on screen. A session with no timing data renders no timing panel in the host either, where Token usage alone is correct.
20
+ - Fix **the workspace session rows' status circle disappearing on 0.1.7-rc.2**: the newer host renders the row's leading seat through a slot outlet, so the seat always carries a `div[data-slot]` anchor (`display: contents`) and is never empty — the circle drawn on `:empty` stopped appearing. The circle now hangs on the empty outlet anchor as well; a seat carrying the running status dot still keeps the dot's own paint.
21
+ - Fix **the account drawer's rows and icons parked above the account row while it is closed**: the self-built drawer's content is reconciled while closed by design (the mirror sync only runs then and freezes while open), so the panel and its gear icon sit right above the account row; opacity alone left that content in the paint and hit-test tree. The closed state now hides it with `visibility` (layout kept, closing fade intact) and the open state restores it.
22
+
23
+ **Full Changelog**: [v0.6.1...v0.6.4](https://github.com/Nwflower/dsh-claude-style/compare/v0.6.1...v0.6.4)
24
+
25
+ ## [0.6.1] - 2026-09-24
26
+
27
+ [中文](#cn-0.6.1) | [English](#en-0.6.1)
28
+
29
+ <h3 id="cn-0.6.1">新增功能</h3>
30
+
31
+ - **适配 DSH 0.1.7 内置自动审查(Auto review)**:分段控件与权限菜单增加审查模式,分段控件显示为 `Auto`、弹层与当前状态显示为 `Auto review`;原 `Auto`(完全权限)更名为 `Yolo`;选择审查模式或完全权限时均正常调起宿主风险确认对话框。审查档位跟随宿主的权限目录:只有宿主的 `permissionPresets` 目录携带 `auto`(内置自动审查已启用)时才显示,目录变化时随之增减;目录读不到时整个权限控件交还宿主的访问模式按钮。
32
+ - **已归档会话的删除按钮恢复可用**:宿主没有给浏览器半边提供删除会话的接口(工作区控制器只有归档与取消归档,agent 协议里的会话删除由宿主委托给持有存储的 ACP agent),插件此前调用第三方插件的路由,该插件不在环境里时点下去没有任何反应。现在由插件的宿主半边新增私有路由 `POST /dsh-claude-style/session-delete` 删除本机会话目录:只接受 POST 与同源请求,id 必须匹配宿主自身的会话 id 形状,正在打开的会话拒绝删除,目录解析后必须留在会话根目录内。
33
+
34
+ <h3 id="en-0.6.1">New Features</h3>
35
+
36
+ - **Support DSH 0.1.7 built-in Auto review**: add review mode to the permission segmented control and popover, displayed as `Auto` in segments and `Auto review` in the menu and current status; rename the former `Auto` (Full access) segment to `Yolo`; selecting either review mode or Full access drives the host's risk confirmation dialog. The review tier follows the host's permission catalog: it appears only while the host's `permissionPresets` catalog carries `auto` (the built-in auto review is enabled), and tracks catalog changes; when the catalog cannot be read, the whole permission control hands the host's access-mode button back.
37
+ - **The archived rows' delete button works again**: the host gives the browser half no way to delete a session (the workspace controller only archives and unarchives, and the agent protocol's session delete is the host delegating to an ACP agent that owns the storage), so the skin used to call a third-party plugin's route and did nothing when that plugin was absent. The plugin's host half now serves a private route, `POST /dsh-claude-style/session-delete`, which removes the local session directory: POST and same-origin requests only, the id must match the host's own session id shape, an open session is refused, and the resolved directory must stay inside the sessions root.
38
+
39
+ **Full Changelog**: [v0.6.0...v0.6.1](https://github.com/Nwflower/dsh-claude-style/compare/v0.6.0...v0.6.1)
40
+
5
41
  ## [0.6.0] - 2026-09-24
6
42
 
7
43
  [中文](#cn-0.6.0) | [English](#en-0.6.0)
package/README.en.md CHANGED
@@ -63,7 +63,7 @@ Keep only one theme enabled at a time. After installation, **restart `dsh web`**
63
63
  ## Features
64
64
 
65
65
  1. **Theme** — applies globally the moment it is installed, with nothing to configure. Light mode uses ivory `#FCFCFB`, dark mode warm black `#141413`, and both share the same ember orange `#D97757` action accent; light/dark follows the system color mode.
66
- 2. **Composer** — the input box is rebuilt from the ground up: a permission segmented control (Full access / Read only / …), a model trigger carrying the vendor lockup, and the toolbar and status stats laid out on a single line, with the send and stop buttons unified into 7px rounded rectangles. The stats sentence and the model trigger share the same font size and color.
66
+ 2. **Composer** — the input box is rebuilt from the ground up: a permission segmented control (Read / Edit / Auto / Yolo — the Auto tier appears only when the host has the built-in auto review enabled), a model trigger carrying the vendor lockup, and the toolbar and status stats laid out on a single line, with the send and stop buttons unified into 7px rounded rectangles. The stats sentence and the model trigger share the same font size and color.
67
67
  3. **Model picker** — a new two-level popover. The first level lists the official service plus the quick providers selected in settings; "More models" opens a second level grouped by provider. Every row carries its vendor lockup and a description, and the footer holds the reasoning-effort slider (stepless — it snaps to the nearest level on release) together with the "More models" entry. The second level aligns its bottom edge with the first, providers already shown on the first level are not repeated, and models without thinking support get no slider. Hovering opens the popover after a 50 ms dwell and closes it after 150 ms; the gap between the two cards does not count as leaving.
68
68
  4. **Workspace** — the sidebar's "Workspace" heading becomes an **Active / Archived** segmented control. Active keeps the host's session tree, while Archived is the skin's own flat list (title, time, and per-row unarchive and delete icon buttons). Rows match the host's session rows measurement for measurement (row x=12 / width 251 / height 28 / title x=36).
69
69
  5. **Sidebar** — the New session and Plugins rows take Claude's real shape: no background at rest, a background on hover, the "+" of New session set inside a circular chip, and both icons rotating 90° clockwise on hover (four-fold symmetric glyphs, so they land back on themselves). The account drawer and the ban-screen easter egg remain as they were.
package/README.md CHANGED
@@ -63,7 +63,7 @@ dsh plugin --profile web add Nwflower/dsh-claude-style # GitHub 源
63
63
  ## 特点
64
64
 
65
65
  1. **主题** —— 安装即全局生效,无需配置。亮色象牙白 `#FCFCFB`、暗色暖黑 `#141413`,两套画布共用同一个操作强调色陶烬橙 `#D97757`;亮暗跟随系统颜色模式切换。
66
- 2. **输入框** —— 输入框整块重做:权限分段控件(完全权限 / 只读 / …)、带品牌锁定标的模型触发器、工具栏与状态统计同一行排版,发送键与停止键统一成 7px 圆角。统计句与模型触发器共用一套字号与颜色。
66
+ 2. **输入框** —— 输入框整块重做:权限分段控件(只读 / 工作区 / 自动审查 / Yolo,自动审查档位仅在宿主启用内置自动审查时出现)、带品牌锁定标的模型触发器、工具栏与状态统计同一行排版,发送键与停止键统一成 7px 圆角。统计句与模型触发器共用一套字号与颜色。
67
67
  3. **模型选择器** —— 全新的两级弹层:一级列官方服务与设置页勾选的快捷供应商,「更多模型」二级按供应商分组;每行带厂商锁定标与说明文案,底部是推理等级推条(无极滑动,松手对齐最近档位)与「更多模型」入口。二级底边与一级对齐,一级已显示的供应商不再重复;模型不支持思考时不画推条。悬停停留 50ms 打开、离开 150ms 关闭,两卡之间的空隙不算离开。
68
68
  4. **工作区** —— 侧栏「工作区」标题改成 **进行中 / 已归档** 分段控件:进行中沿用宿主的会话树,已归档是皮肤自己的平铺列表(标题、时间、每行「取消归档」与「删除」两个图标按钮)。行排版与宿主的会话行逐项一致(行 x=12 / 宽 251 / 高 28 / 标题起点 x=36)。
69
69
  5. **侧栏** —— 新会话与插件两行取 Claude 真机形状:默认无底色、hover 才有底色,新会话的「+」套一枚圆形底,两行图标 hover 顺时针转 90°(四重对称图形,转回原位);底部账户抽屉、封号彩蛋一并保留。
@@ -84,7 +84,7 @@
84
84
  ## D11. 插件自有路由借宿主的请求栅栏;宿主/用户来源的字符串只以文本上屏
85
85
 
86
86
  - **背景**:`webServer.register()` 交给插件的是裸请求。宿主自己的 `/api` 挂在 Host/Origin 栅栏与浏览器会话 cookie 之后(`connection.requestRejection()`),插件路由不在其内。本插件的 `/username`(读系统用户名)因此曾经完全无鉴权:`dsh web` 绑定 `0.0.0.0` 时局域网里任何人都能直接读。读到的用户名又被客户端拼进 `innerHTML`——实测在 GUI 页面里执行了脚本,而这个页面能驱动执行 shell 命令的智能体。
87
- - **决定**:宿主半边 `/username` 处理前先调 `ctx.get('connection').requestRejection(req)`,拒绝即回 401/403。宿主没有该服务时,插件自带的替身只服务回环(回环 Host、无跨站标记、Origin 与 Host 一致)。模型文案与字体等静态资产保持公开。客户端:一切来自设置、账号服务、系统或第三方插件的字符串只用 `textContent` / 元素属性写入,图标复制节点而非重新解析 markup。
87
+ - **决定**:宿主半边 `/username` 处理前先调 `ctx.get('connection').requestRejection(req)`,拒绝即回 401/403。宿主没有该服务时,插件自带的替身只服务回环(回环 Host、无跨站标记、Origin 与 Host 一致)。模型文案与字体等静态资产保持公开。删除会话的私有路由 `POST /dsh-claude-style/session-delete` 同样先过栅栏,另加四道:只接受 POST,id 必须匹配宿主自身的会话 id 形状,正在打开的会话拒绝,目录解析后必须留在会话根目录内(删除发生在宿主半边,浏览器半边只提交一个 id)。客户端:一切来自设置、账号服务、系统或第三方插件的字符串只用 `textContent` / 元素属性写入,图标复制节点而非重新解析 markup。
88
88
  - **代价**:路由依赖宿主的 connection 服务。已核对两种壳都能通过:浏览器同源请求带会话 cookie;0.1.7 桌面壳经 `forwardWebRequest` 转发到回环 Host、剥掉页面 Origin 并自带 cookie(宿主真实的 `isTrustedApiRequest` 对这两种请求放行,对跨站与 DNS 重绑定请求拒绝)。
89
89
  - **重审条件**:宿主为插件提供自带鉴权的路由注册(如 `connection.fetch.register`)时,迁移过去并删掉本地替身。
90
90
 
package/lib/client.js CHANGED
@@ -199,7 +199,8 @@ window.__ModuleLoader__.load({
199
199
  var PERMISSION_SEGMENTS = [
200
200
  { label: 'Read', preset: 'read-only' },
201
201
  { label: 'Edit', preset: 'workspace-write' },
202
- { label: 'Auto', preset: 'danger-full-access' },
202
+ { label: 'Auto', preset: 'auto' },
203
+ { label: 'Yolo', preset: 'danger-full-access' },
203
204
  ]
204
205
 
205
206
  /** In-conversation permission popover options matching Claude Code desktop style. */
@@ -214,6 +215,11 @@ window.__ModuleLoader__.load({
214
215
  label: 'Accept edits',
215
216
  desc: '允许编辑工作区文件'
216
217
  },
218
+ {
219
+ preset: 'auto',
220
+ label: 'Auto review',
221
+ desc: '无沙箱运行,调用前由模型审查'
222
+ },
217
223
  {
218
224
  preset: 'danger-full-access',
219
225
  label: 'Full access',
@@ -221,12 +227,15 @@ window.__ModuleLoader__.load({
221
227
  }
222
228
  ]
223
229
 
224
- /** The one preset the shipped UI gates behind its risk-confirmation dialog. */
230
+ /** The presets the shipped UI gates behind its risk-confirmation dialog. */
225
231
  var GATED_PRESET = 'danger-full-access'
226
- /** Shipped full-access row labels, used to find that row in the shipped menu. */
232
+ var AUTO_REVIEW_PRESET = 'auto'
233
+ /** Shipped risk-gated row labels, used to find those rows in the shipped menu. */
227
234
  var FULL_ACCESS_LABELS = ['完全权限', 'Full access']
228
- /** Fallback prompt, used only when the shipped menu cannot be reached. */
229
- var GATED_PROMPT = '启用完全权限(Auto)?\n\n智能体将减少确认步骤,可直接执行敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。'
235
+ var AUTO_REVIEW_LABELS = ['Auto review', 'Auto review EXP']
236
+ /** Fallback prompts, used only when the shipped menu cannot be reached. */
237
+ var GATED_PROMPT = '启用完全权限(Yolo)?\n\n智能体将减少确认步骤,可直接执行敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。'
238
+ var AUTO_REVIEW_PROMPT = '启用 Auto review(实验)?\n\nAuto review 不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。'
230
239
 
231
240
  /** Skin-owned class names, so nothing couples to hashed CSS-module classes. */
232
241
  var SEGMENTS_CLASS = 'dsh-claude-segments'
@@ -307,6 +316,14 @@ window.__ModuleLoader__.load({
307
316
  * document and the stylesheet switches on this attribute.
308
317
  */
309
318
  var WINDOW_BLUR_ATTR = 'data-dsh-window-blur'
319
+ /**
320
+ * The host half's session-deletion route (lib/index.js, SESSION_DELETE_PATH).
321
+ * The harness gives the browser half no deletion API of its own, so the
322
+ * archived row's delete button posts the session id here and the host half
323
+ * removes the stored session directory. Keep the path in step with the host
324
+ * half.
325
+ */
326
+ var SESSION_DELETE_ROUTE = '/dsh-claude-style/session-delete'
310
327
  /** Composer surfaces the restyle may cover, in settings order. */
311
328
  var COMPOSER_SCOPES = ['off', 'hero', 'conversation', 'all']
312
329
  /**
@@ -2903,7 +2920,12 @@ window.__ModuleLoader__.load({
2903
2920
  "body[data-dsh-claude-style] [class*=\"projectRow\"][aria-expanded=\"false\"] [class*=\"chevron\"]::after {",
2904
2921
  " transform: rotate(-90deg);",
2905
2922
  "}",
2906
- "/* Session status circle indicator (matching Claude style) */",
2923
+ "/* Session status circle indicator (matching Claude style). The host's newer",
2924
+ " sidebar renders the leading seat through a slot outlet, whose anchor",
2925
+ " (`div[data-slot]`, display:contents) is a child of the seat even when nothing",
2926
+ " is registered — an idle row's seat is therefore no longer :empty, so the",
2927
+ " circle hangs on the empty anchor as well. A seat carrying status dots or",
2928
+ " another plugin's content keeps its own paint and gets no circle. */",
2907
2929
  "body[data-dsh-claude-style] [class*=\"sessionRow\"] [class*=\"slot\"] {",
2908
2930
  " width: 14px !important;",
2909
2931
  " height: 16px !important;",
@@ -2911,7 +2933,8 @@ window.__ModuleLoader__.load({
2911
2933
  " align-items: center !important;",
2912
2934
  " justify-content: center !important;",
2913
2935
  "}",
2914
- "body[data-dsh-claude-style] [class*=\"sessionRow\"] [class*=\"slot\"]:empty::after {",
2936
+ "body[data-dsh-claude-style] [class*=\"sessionRow\"] [class*=\"slot\"]:empty::after,",
2937
+ "body[data-dsh-claude-style] [class*=\"sessionRow\"] [class*=\"slot\"] > [data-slot]:empty::after {",
2915
2938
  " content: \"\";",
2916
2939
  " display: inline-block;",
2917
2940
  " width: 5px;",
@@ -2988,7 +3011,7 @@ window.__ModuleLoader__.load({
2988
3011
  "/* --- 2.4 Custom Components (Segments & Account Popover) --- */",
2989
3012
  "/* ---------- Claude Code layout: permission segments ---------- */",
2990
3013
  "/* The shipped access control is one popup-select button; it is replaced by",
2991
- " a Read | Edit | Auto segmented control over the same three presets — for",
3014
+ " a Read | Edit | Auto | Yolo segmented control over presets — for",
2992
3015
  " as long as the permission control is installed to stand in for it. */",
2993
3016
  "body[data-dsh-claude-style][data-dsh-claude-composer-active][data-dsh-claude-permissions] button[aria-label^=\"访问模式\"],",
2994
3017
  "body[data-dsh-claude-style][data-dsh-claude-composer-active][data-dsh-claude-permissions] button[aria-label^=\"Access mode\"] {",
@@ -3039,6 +3062,14 @@ window.__ModuleLoader__.load({
3039
3062
  " box-shadow: 0 1px 2px rgba(20, 20, 19, 0.08);",
3040
3063
  "}",
3041
3064
  "",
3065
+ "/* Rows whose preset the host's permission catalog does not carry (the Auto",
3066
+ " review tier without the auto-review integration) are taken out of the",
3067
+ " control entirely. */",
3068
+ "body[data-dsh-claude-style] .dsh-claude-segment[data-hidden],",
3069
+ "body[data-dsh-claude-style] .dsh-claude-perm-popover .dsh-claude-popover-item[data-hidden] {",
3070
+ " display: none !important;",
3071
+ "}",
3072
+ "",
3042
3073
  "/* ---------- Claude Code layout: in-conversation permission popover ---------- */",
3043
3074
  "body[data-dsh-claude-style] .dsh-claude-perm-container {",
3044
3075
  " position: relative !important;",
@@ -3230,15 +3261,24 @@ window.__ModuleLoader__.load({
3230
3261
  " flex-direction: column !important;",
3231
3262
  " gap: 6px !important;",
3232
3263
  " opacity: 0 !important;",
3264
+ " /* The drawer's rows are built and reconciled while it is closed (the mirror",
3265
+ " sync only runs then), so the panel sits right above the account row with",
3266
+ " its icons in it. opacity alone leaves that content in the paint and",
3267
+ " hit-test tree; visibility takes it out of both, and the delayed transition",
3268
+ " keeps the closing fade. Layout is untouched, so the closed drawer's box is",
3269
+ " still measurable. */",
3270
+ " visibility: hidden !important;",
3233
3271
  " pointer-events: none !important;",
3234
3272
  " transform: translateY(6px) scale(0.98) !important;",
3235
3273
  " transform-origin: bottom left !important;",
3236
- " transition: opacity 0.15s ease, transform 0.15s ease !important;",
3274
+ " transition: opacity 0.15s ease, transform 0.15s ease, visibility 0s linear 0.15s !important;",
3237
3275
  "}",
3238
3276
  "body[data-dsh-claude-style] .dsh-claude-account-popover[data-open=\"true\"] {",
3239
3277
  " opacity: 1 !important;",
3278
+ " visibility: visible !important;",
3240
3279
  " pointer-events: auto !important;",
3241
3280
  " transform: translateY(0) scale(1) !important;",
3281
+ " transition: opacity 0.15s ease, transform 0.15s ease, visibility 0s !important;",
3242
3282
  "}",
3243
3283
  "body[data-dsh-claude-style][data-ds-dark-theme] .dsh-claude-account-popover {",
3244
3284
  " background: #1e1e1d !important;",
@@ -6706,49 +6746,114 @@ window.__ModuleLoader__.load({
6706
6746
  return rows
6707
6747
  }
6708
6748
 
6709
- function statsPanelData(selector) {
6710
- var panel = document.querySelector(selector)
6711
- return {
6712
- title: panel === null ? '' : (panel.getAttribute('aria-label') || ''),
6713
- rows: statsRowsFrom(panel),
6749
+ /** How long one pill's panel may take to mount (the host commits on its own schedule). */
6750
+ var STATS_PANEL_POLL_MS = 25
6751
+ var STATS_PANEL_ATTEMPTS = 32
6752
+ /** When a click is repeated inside that window, and how often. */
6753
+ var STATS_PANEL_REPRESS_AT = 12
6754
+ var STATS_PANEL_REPRESS_MAX = 2
6755
+ /** How often a read that could not open a panel is retried, and after what delay. */
6756
+ var STATS_READ_RETRIES = 1
6757
+ var STATS_RETRY_MS = 120
6758
+ /** How long after a short card the one late re-read runs. */
6759
+ var STATS_FILL_MS = 700
6760
+
6761
+ /** The stats panel that is open right now, with the kind its own marker names. */
6762
+ function openStatsPanel() {
6763
+ var details = document.querySelector('[data-session-stats-details]')
6764
+ if (details !== null) {
6765
+ var detailsDialog = details.closest('[role="dialog"]')
6766
+ if (detailsDialog !== null) return { kind: 'details', panel: detailsDialog }
6767
+ }
6768
+ var usage = document.querySelector('[data-session-stats-usage]')
6769
+ if (usage !== null) {
6770
+ var usageDialog = usage.closest('[role="dialog"]')
6771
+ if (usageDialog !== null) return { kind: 'usage', panel: usageDialog }
6714
6772
  }
6773
+ return null
6715
6774
  }
6716
6775
 
6717
- function withStatsPanel(button, selector, done) {
6718
- if (button === null) { done({ title: '', rows: [] }); return }
6719
- if (button.getAttribute('aria-expanded') !== 'true') button.click()
6776
+ /** The row's pill buttons as they are right now (the host replaces them on re-render). */
6777
+ function statsPillAt(index) {
6778
+ var root = statsRoot()
6779
+ if (root === null) return null
6780
+ var buttons = root.querySelectorAll('button')
6781
+ return index < buttons.length ? buttons[index] : null
6782
+ }
6783
+
6784
+ /**
6785
+ * Open one pill, read whichever stats panel it shows, and close it again.
6786
+ *
6787
+ * The read is kind-agnostic on purpose: which panel appears is the host's
6788
+ * own answer, so a pill is never mislabelled by its label text (the timing
6789
+ * pill's label carries a tok/s reading as well, and the timing pill is a
6790
+ * plain span — no trigger at all — while the session has no timing yet).
6791
+ * The panel counts only while that pill reports itself expanded, so a
6792
+ * sibling's panel that has not unmounted yet is never read as this one's.
6793
+ * The pill is re-resolved from the DOM at every step and pressed again
6794
+ * while the window lasts: the host re-renders the row on its own schedule,
6795
+ * and a click on a node it has since replaced goes nowhere. `null` means
6796
+ * the row has no such pill; a pill that shows no panel inside the window
6797
+ * reports a failure, which is not an absent section.
6798
+ */
6799
+ function readStatsPill(index, done) {
6800
+ var presses = 0
6801
+ function press() {
6802
+ var pill = statsPillAt(index)
6803
+ if (pill === null) return false
6804
+ if (pill.getAttribute('aria-expanded') !== 'true') pill.click()
6805
+ return true
6806
+ }
6807
+ if (!press()) { done(null); return }
6720
6808
  var attempts = 0
6721
6809
  function read() {
6722
- var panel = document.querySelector(selector)
6723
- if (panel === null && attempts < 15) {
6810
+ var pill = statsPillAt(index)
6811
+ if (pill === null) { done({ failed: true, rows: [] }); return }
6812
+ var open = pill.getAttribute('aria-expanded') === 'true' ? openStatsPanel() : null
6813
+ if (open === null && attempts < STATS_PANEL_ATTEMPTS) {
6724
6814
  attempts += 1
6725
- setTimeout(read, 20)
6815
+ if (attempts === STATS_PANEL_REPRESS_AT && presses < STATS_PANEL_REPRESS_MAX) {
6816
+ presses += 1
6817
+ press()
6818
+ }
6819
+ setTimeout(read, STATS_PANEL_POLL_MS)
6726
6820
  return
6727
6821
  }
6728
- var data = statsPanelData(selector)
6729
- if (button.getAttribute('aria-expanded') === 'true') button.click()
6730
- done(data)
6822
+ if (pill.getAttribute('aria-expanded') === 'true') pill.click()
6823
+ if (open === null) { done({ failed: true, rows: [] }); return }
6824
+ done({ kind: open.kind, title: open.panel.getAttribute('aria-label') || '', rows: statsRowsFrom(open.panel) })
6731
6825
  }
6732
- setTimeout(read, 20)
6826
+ setTimeout(read, STATS_PANEL_POLL_MS)
6733
6827
  }
6734
6828
 
6829
+ /**
6830
+ * Read both of the host's pills.
6831
+ *
6832
+ * `done(sections, incomplete)`: the sections in the host's own order
6833
+ * (session statistics, then token usage), and `incomplete` true when a
6834
+ * pill the row carries could not be read — the caller must not mistake
6835
+ * that for a section the host does not have.
6836
+ */
6735
6837
  function collectStatsData(done) {
6736
- var root = statsRoot()
6737
- if (root === null) { done([]); return }
6738
- var buttons = root.querySelectorAll('button')
6739
- var timeBtn = null
6740
- var usageBtn = null
6741
- for (var i = 0; i < buttons.length; i++) {
6742
- var aria = buttons[i].getAttribute('aria-label') || ''
6743
- if (/轮|步|turns?|steps?/i.test(aria)) timeBtn = buttons[i]
6744
- else usageBtn = buttons[i]
6745
- }
6746
- withStatsPanel(timeBtn, '[role="dialog"]:has([data-session-stats-details])', function (timeData) {
6747
- withStatsPanel(usageBtn, '[role="dialog"]:has([data-session-stats-usage])', function (usageData) {
6838
+ if (statsRoot() === null) { done([], false); return }
6839
+ readStatsPill(0, function (first) {
6840
+ readStatsPill(1, function (second) {
6841
+ var reads = [first, second]
6748
6842
  var sections = []
6749
- if (timeData.rows.length > 0) sections.push(timeData)
6750
- if (usageData.rows.length > 0) sections.push(usageData)
6751
- done(sections)
6843
+ var incomplete = false
6844
+ for (var i = 0; i < reads.length; i++) {
6845
+ var read = reads[i]
6846
+ if (read === null) continue
6847
+ if (read.failed === true || read.rows.length === 0) {
6848
+ incomplete = true
6849
+ continue
6850
+ }
6851
+ sections.push(read)
6852
+ }
6853
+ sections.sort(function (a, b) {
6854
+ return (a.kind === 'details' ? 0 : 1) - (b.kind === 'details' ? 0 : 1)
6855
+ })
6856
+ done(sections, incomplete)
6752
6857
  })
6753
6858
  })
6754
6859
  }
@@ -6816,12 +6921,34 @@ window.__ModuleLoader__.load({
6816
6921
  pop.innerHTML = html
6817
6922
  }
6818
6923
 
6819
- /** The last full read; a thinner one never replaces it while the card is up. */
6924
+ /** Whether the card is up, so a failed re-read can leave its content alone. */
6925
+ function isStatsPopoverOpen() {
6926
+ return statsPopover !== null && statsPopover.getAttribute('data-open') === 'true'
6927
+ }
6820
6928
 
6821
- var statsSections = null
6929
+ /** How many sections the card currently shows. */
6930
+ var renderedSections = 0
6822
6931
 
6823
- var statsSectionsAt = 0
6932
+ /** Place the card above the stats row, clamped to the viewport. */
6933
+ function placeStatsPopover(anchor) {
6934
+ var pop = ensureStatsPopover()
6935
+ var live = statsRoot() || anchor
6936
+ if (live === null) return
6937
+ var rect = live.getBoundingClientRect()
6938
+ var width = pop.offsetWidth
6939
+ var height = pop.offsetHeight
6940
+ var left = Math.max(8, Math.min(rect.left + rect.width / 2 - width / 2, window.innerWidth - width - 8))
6941
+ var top = Math.max(8, rect.top - height - 8)
6942
+ pop.style.left = left + 'px'
6943
+ pop.style.top = top + 'px'
6944
+ }
6824
6945
 
6946
+ function showStatsSections(anchor, sections) {
6947
+ renderStatsPopover(sections)
6948
+ renderedSections = sections.length
6949
+ ensureStatsPopover().setAttribute('data-open', 'true')
6950
+ placeStatsPopover(anchor)
6951
+ }
6825
6952
 
6826
6953
  function showStatsPopover(anchor) {
6827
6954
  // The card reads the host's two dialogs, which only the detailed row
@@ -6833,29 +6960,40 @@ window.__ModuleLoader__.load({
6833
6960
  clearTimeout(statsHideTimer)
6834
6961
  statsHideTimer = null
6835
6962
  }
6836
- collectStatsData(function (sections) {
6963
+ readStatsCard(anchor, 0)
6964
+ }
6965
+
6966
+ /**
6967
+ * Collect the host's panels and render the card.
6968
+ *
6969
+ * A read that could not open one of the panels is retried, and one that
6970
+ * still comes back short while the card is already up leaves the card as
6971
+ * it is: the host's panels mount on its own commit, so a read that lost
6972
+ * the race must never read as a section the host does not have — that was
6973
+ * the 'click and it drops to Token usage only' card. A card that did come
6974
+ * up short schedules one late re-read, which fills it in and never
6975
+ * shrinks it.
6976
+ */
6977
+ function readStatsCard(anchor, attempt) {
6978
+ collectStatsData(function (sections, incomplete) {
6837
6979
  if (sections.length === 0) return
6838
- // A click re-collects, and the host's two pills can be mid-flight then: a read
6839
- // that comes back with fewer sections than the card already shows must not
6840
- // shrink it — that is the 'click and it drops to Token usage only' bug.
6841
- var fresh = Date.now() - statsSectionsAt < 180000
6842
- if (statsSections !== null && fresh && sections.length < statsSections.length) {
6843
- sections = statsSections
6844
- } else {
6845
- statsSections = sections
6846
- statsSectionsAt = Date.now()
6980
+ if (incomplete && attempt < STATS_READ_RETRIES) {
6981
+ setTimeout(function () { readStatsCard(anchor, attempt + 1) }, STATS_RETRY_MS)
6982
+ return
6847
6983
  }
6848
- renderStatsPopover(sections)
6849
- var pop = ensureStatsPopover()
6850
- pop.setAttribute('data-open', 'true')
6851
- var live = statsRoot() || anchor
6852
- var rect = live.getBoundingClientRect()
6853
- var width = pop.offsetWidth
6854
- var height = pop.offsetHeight
6855
- var left = Math.max(8, Math.min(rect.left + rect.width / 2 - width / 2, window.innerWidth - width - 8))
6856
- var top = Math.max(8, rect.top - height - 8)
6857
- pop.style.left = left + 'px'
6858
- pop.style.top = top + 'px'
6984
+ if (incomplete && isStatsPopoverOpen()) return
6985
+ showStatsSections(anchor, sections)
6986
+ if (incomplete) setTimeout(function () { fillStatsCard(anchor) }, STATS_FILL_MS)
6987
+ })
6988
+ }
6989
+
6990
+ /** One late re-read for a card that came up short; it only ever adds. */
6991
+ function fillStatsCard(anchor) {
6992
+ collectStatsData(function (sections, incomplete) {
6993
+ if (incomplete || sections.length === 0) return
6994
+ if (!isStatsPopoverOpen()) return
6995
+ if (sections.length <= renderedSections) return
6996
+ showStatsSections(anchor, sections)
6859
6997
  })
6860
6998
  }
6861
6999
 
@@ -7015,6 +7153,134 @@ window.__ModuleLoader__.load({
7015
7153
  var permPopover = null
7016
7154
  var permHoverIntent = null
7017
7155
 
7156
+ /**
7157
+ * Whether the host's permission catalog carries the live Auto review
7158
+ * preset (`auto`, registered by the auto-review plugin): true or false
7159
+ * once a catalog read settled, null until then. While unknown the rows
7160
+ * stay hidden, the way the shipped picker renders nothing before its
7161
+ * first catalog read.
7162
+ */
7163
+ var autoPresetLive = null
7164
+ /** The first catalog failure; thrown on the next sync to retire the feature. */
7165
+ var autoPresetError = null
7166
+ var catalogFiber = null
7167
+ var catalogChangedDisposer = null
7168
+
7169
+ /** The Auto review rows are only offered while the host's catalog carries the preset. */
7170
+ function rowVisible(preset) {
7171
+ return preset !== AUTO_REVIEW_PRESET || autoPresetLive === true
7172
+ }
7173
+
7174
+ /**
7175
+ * Mirror the availability onto a row as data-hidden; the stylesheet turns
7176
+ * that into display:none (an inline style would lose to the popover
7177
+ * item's own `display: flex !important`).
7178
+ */
7179
+ function syncRowVisibility(row, preset) {
7180
+ if (rowVisible(preset)) {
7181
+ if (row.hasAttribute('data-hidden')) row.removeAttribute('data-hidden')
7182
+ } else if (!row.hasAttribute('data-hidden')) {
7183
+ row.setAttribute('data-hidden', '')
7184
+ }
7185
+ }
7186
+
7187
+ /**
7188
+ * Read the host's permission catalog and record whether it carries the
7189
+ * live Auto review preset. The shipped picker builds its rows from the
7190
+ * same catalog, so it is the authority on what is switchable. A rejected
7191
+ * read retries like the account profile's reads (the client connection
7192
+ * may still be coming up at install); once the retries are exhausted the
7193
+ * failure is remembered and thrown on the next sync, which retires this
7194
+ * feature and hands the shipped access button back (D12).
7195
+ */
7196
+ var AUTO_PRESET_RETRY_MS = [1000, 5000]
7197
+ var autoPresetRetries = 0
7198
+ var autoPresetRead = 0
7199
+ var autoPresetRetry = null
7200
+
7201
+ /** Invalidate the read in flight and drop any retry still waiting. */
7202
+ function dropAutoPresetRead() {
7203
+ autoPresetRead++
7204
+ if (autoPresetRetry !== null) {
7205
+ clearTimeout(autoPresetRetry)
7206
+ autoPresetRetry = null
7207
+ }
7208
+ }
7209
+
7210
+ function probeAutoPreset() {
7211
+ var namespace = null
7212
+ try { namespace = ctx.get('remote.permissionPresets') } catch (error) { namespace = null }
7213
+ if (namespace === null || namespace === void 0 || typeof namespace.catalog !== 'function') {
7214
+ if (typeof ctx.inject !== 'function') {
7215
+ autoPresetError = new Error('permission: the host exposes no remote.permissionPresets catalog')
7216
+ ui.schedule()
7217
+ }
7218
+ return
7219
+ }
7220
+ dropAutoPresetRead()
7221
+ var read = autoPresetRead
7222
+ namespace.catalog().then(function (result) {
7223
+ if (read !== autoPresetRead) return
7224
+ if (result === null || typeof result !== 'object' || result.ok !== true ||
7225
+ result.value === null || typeof result.value !== 'object' ||
7226
+ !Array.isArray(result.value.options)) {
7227
+ autoPresetError = new Error('permission: unexpected permissionPresets catalog shape')
7228
+ ui.schedule()
7229
+ return
7230
+ }
7231
+ autoPresetRetries = 0
7232
+ autoPresetError = null
7233
+ var live = false
7234
+ for (var i = 0; i < result.value.options.length; i++) {
7235
+ var option = result.value.options[i]
7236
+ if (option !== null && typeof option === 'object' && option.value === AUTO_REVIEW_PRESET) {
7237
+ live = true
7238
+ break
7239
+ }
7240
+ }
7241
+ autoPresetLive = live
7242
+ ui.schedule()
7243
+ }, function () {
7244
+ if (read !== autoPresetRead) return
7245
+ if (autoPresetRetries >= AUTO_PRESET_RETRY_MS.length) {
7246
+ autoPresetError = new Error('permission: the permissionPresets catalog read failed ' + (AUTO_PRESET_RETRY_MS.length + 1) + ' times')
7247
+ ui.schedule()
7248
+ return
7249
+ }
7250
+ autoPresetRetry = setTimeout(function () {
7251
+ autoPresetRetry = null
7252
+ probeAutoPreset()
7253
+ }, AUTO_PRESET_RETRY_MS[autoPresetRetries++])
7254
+ })
7255
+ }
7256
+
7257
+ /**
7258
+ * The namespace may register after this plugin (the host registers each
7259
+ * remote namespace as its package loads), so wait for it the way the
7260
+ * account row waits for remote.account; a host without inject gets one
7261
+ * read now. The catalog-changed event re-reads when the auto-review
7262
+ * integration is registered or dropped while the page stays open.
7263
+ */
7264
+ function startAutoPresetProbe() {
7265
+ if (typeof ctx.inject === 'function') {
7266
+ catalogFiber = ctx.inject(['remote.permissionPresets'], function (scope) {
7267
+ scope.effect(function () {
7268
+ probeAutoPreset()
7269
+ return function () {}
7270
+ }, 'dsh-claude-style: permission catalog')
7271
+ })
7272
+ } else {
7273
+ probeAutoPreset()
7274
+ }
7275
+ var remoteRoot = null
7276
+ try { remoteRoot = ctx.get('remote') } catch (error) { remoteRoot = null }
7277
+ if (remoteRoot !== null && remoteRoot !== void 0 && typeof remoteRoot.$on === 'function') {
7278
+ catalogChangedDisposer = remoteRoot.$on('permission-presets/catalog-changed', function () {
7279
+ probeAutoPreset()
7280
+ })
7281
+ }
7282
+ }
7283
+
7018
7284
  function buildSegments(onPick) {
7019
7285
  var group = document.createElement('div')
7020
7286
  group.className = SEGMENTS_CLASS
@@ -7183,7 +7449,9 @@ window.__ModuleLoader__.load({
7183
7449
 
7184
7450
  function updatePermState(preset) {
7185
7451
  if (!permLabel || !permPopover) return
7186
- var matchedLabel = 'Accept edits'
7452
+ // A preset our option list does not carry (the host's `custom`, or a
7453
+ // row this host's catalog does not offer) shows its machine value.
7454
+ var matchedLabel = preset === null ? 'Accept edits' : preset
7187
7455
  for (var i = 0; i < PERMISSION_OPTIONS.length; i++) {
7188
7456
  if (PERMISSION_OPTIONS[i].preset === preset) {
7189
7457
  matchedLabel = PERMISSION_OPTIONS[i].label
@@ -7198,7 +7466,8 @@ window.__ModuleLoader__.load({
7198
7466
  var items = permPopover.querySelectorAll('[data-preset]')
7199
7467
  for (var j = 0; j < items.length; j++) {
7200
7468
  var it = items[j]
7201
- var isCurrent = it.getAttribute('data-preset') === preset
7469
+ var rowPreset = it.getAttribute('data-preset')
7470
+ var isCurrent = rowPreset === preset
7202
7471
  var check = it.querySelector('.dsh-claude-perm-check')
7203
7472
  if (check) {
7204
7473
  check.style.display = isCurrent ? 'inline' : 'none'
@@ -7208,6 +7477,7 @@ window.__ModuleLoader__.load({
7208
7477
  } else {
7209
7478
  it.removeAttribute('data-active')
7210
7479
  }
7480
+ syncRowVisibility(it, rowPreset)
7211
7481
  }
7212
7482
  }
7213
7483
 
@@ -7219,29 +7489,42 @@ window.__ModuleLoader__.load({
7219
7489
  }
7220
7490
 
7221
7491
  /**
7222
- * Drive the shipped access menu to its full-access row, so the switch
7223
- * runs through the shipped risk-confirmation dialog rather than a
7492
+ * Drive the shipped access menu to its risk-gated row (Full access or Auto review),
7493
+ * so the switch runs through the shipped risk-confirmation dialog rather than a
7224
7494
  * skin-owned prompt. The trigger is hidden by this skin but still in
7225
7495
  * the tree, so a synthetic click still opens the menu; the row is then
7226
7496
  * picked by its label. A plain confirm stands in only when that menu
7227
7497
  * cannot be reached, which keeps the switch behind an explicit
7228
7498
  * acknowledgement either way.
7229
7499
  */
7230
- function openShippedGate() {
7500
+ var gateInFlight = false
7501
+
7502
+ function openShippedGate(preset) {
7503
+ if (gateInFlight) return
7504
+ var isAuto = preset === AUTO_REVIEW_PRESET
7505
+ var targetLabels = isAuto ? AUTO_REVIEW_LABELS : FULL_ACCESS_LABELS
7506
+ var prompt = isAuto ? AUTO_REVIEW_PROMPT : GATED_PROMPT
7231
7507
  var trigger = findAccessTrigger()
7232
7508
  if (trigger === null) {
7233
- if (window.confirm(GATED_PROMPT)) submitPreset(GATED_PRESET)
7509
+ if (window.confirm(prompt)) submitPreset(preset)
7234
7510
  return
7235
7511
  }
7512
+ gateInFlight = true
7236
7513
  trigger.click()
7237
7514
  var attempts = 0
7238
7515
  function seek() {
7239
- var items = document.querySelectorAll('[role="menu"] button[role="menuitem"]')
7516
+ // The skin's own popover is itself a role=menu whose row texts start
7517
+ // with the same labels, so it must stay out of this search — matching
7518
+ // it would click back into pick() and toggle the shipped menu every
7519
+ // frame. The shipped Auto review row concatenates its EXP badge into
7520
+ // the text ("Auto reviewEXP"), so the label matches as a prefix.
7521
+ var items = document.querySelectorAll('[role="menu"]:not(.dsh-claude-perm-popover) button[role="menuitem"]')
7240
7522
  for (var i = 0; i < items.length; i++) {
7241
7523
  var text = (items[i].textContent || '').trim()
7242
- for (var j = 0; j < FULL_ACCESS_LABELS.length; j++) {
7243
- if (text === FULL_ACCESS_LABELS[j]) {
7524
+ for (var j = 0; j < targetLabels.length; j++) {
7525
+ if (text.indexOf(targetLabels[j]) === 0) {
7244
7526
  items[i].click()
7527
+ gateInFlight = false
7245
7528
  return
7246
7529
  }
7247
7530
  }
@@ -7251,8 +7534,9 @@ window.__ModuleLoader__.load({
7251
7534
  requestAnimationFrame(seek)
7252
7535
  return
7253
7536
  }
7537
+ gateInFlight = false
7254
7538
  trigger.click()
7255
- if (window.confirm(GATED_PROMPT)) submitPreset(GATED_PRESET)
7539
+ if (window.confirm(prompt)) submitPreset(preset)
7256
7540
  }
7257
7541
  requestAnimationFrame(seek)
7258
7542
  }
@@ -7261,8 +7545,8 @@ window.__ModuleLoader__.load({
7261
7545
  var session = currentSession(ctx)
7262
7546
  if (session === null || preset === null) return
7263
7547
  if (preset === currentPreset(session)) return
7264
- if (preset === GATED_PRESET) {
7265
- openShippedGate()
7548
+ if (preset === GATED_PRESET || preset === AUTO_REVIEW_PRESET) {
7549
+ openShippedGate(preset)
7266
7550
  return
7267
7551
  }
7268
7552
  submitPreset(preset)
@@ -7327,6 +7611,7 @@ window.__ModuleLoader__.load({
7327
7611
  }
7328
7612
  for (var j = 0; j < segments.children.length; j++) {
7329
7613
  var item = segments.children[j]
7614
+ syncRowVisibility(item, item.getAttribute('data-preset'))
7330
7615
  if (item.getAttribute('data-preset') === preset) {
7331
7616
  item.setAttribute('data-active', '')
7332
7617
  item.setAttribute('aria-checked', 'true')
@@ -7366,6 +7651,7 @@ window.__ModuleLoader__.load({
7366
7651
 
7367
7652
  ui.permissions = {
7368
7653
  sync: function () {
7654
+ if (autoPresetError !== null) throw autoPresetError
7369
7655
  stats.sync()
7370
7656
  syncSegments()
7371
7657
  },
@@ -7383,10 +7669,20 @@ window.__ModuleLoader__.load({
7383
7669
  // The composer restyle hides the host's access button and statistics
7384
7670
  // dialogs only while this says their replacement is installed.
7385
7671
  document.body.setAttribute(PERMISSIONS_ATTR, '')
7672
+ startAutoPresetProbe()
7386
7673
 
7387
7674
  return function () {
7388
7675
  stats.teardown()
7389
7676
  if (permHoverIntent) permHoverIntent.cancel()
7677
+ dropAutoPresetRead()
7678
+ if (catalogFiber !== null && typeof catalogFiber.dispose === 'function') {
7679
+ try { catalogFiber.dispose() } catch (error) { /* the fiber may already be gone */ }
7680
+ catalogFiber = null
7681
+ }
7682
+ if (catalogChangedDisposer !== null) {
7683
+ catalogChangedDisposer()
7684
+ catalogChangedDisposer = null
7685
+ }
7390
7686
  if (permDocPointerListener) {
7391
7687
  document.removeEventListener('pointerdown', permDocPointerListener)
7392
7688
  permDocPointerListener = null
@@ -7398,6 +7694,7 @@ window.__ModuleLoader__.load({
7398
7694
  }
7399
7695
  removeStrayNodes(document, '.' + SEGMENTS_CLASS + '[data-composer-segments], .dsh-claude-perm-container', [])
7400
7696
  segments = null
7697
+ gateInFlight = false
7401
7698
  if (permPopover !== null && permPopover.parentElement !== null) permPopover.parentElement.removeChild(permPopover)
7402
7699
  permPopover = null
7403
7700
  permBtn = null
@@ -10872,6 +11169,12 @@ window.__ModuleLoader__.load({
10872
11169
  var footerEntries = syncFooterActionVisibility(footerActions)
10873
11170
  var body = options.body()
10874
11171
 
11172
+ // A closed host drawer has no container to mirror into, but the entries
11173
+ // above are hidden in place all the same: the host re-renders them, and a
11174
+ // pass that skips this leaves their icons painted in the sidebar until the
11175
+ // drawer is opened once.
11176
+ if (body === null) return
11177
+
10875
11178
  // While the popover is open, its mirrors must stay completely static:
10876
11179
  // a content rewrite, reorder, or embedded-clone replacement under the
10877
11180
  // pointer cancels the browser's :hover state and can swallow the click
@@ -11562,10 +11865,15 @@ window.__ModuleLoader__.load({
11562
11865
  syncAccountRowBox(entryRow, footArea)
11563
11866
  if (hostTrigger !== null) bindHostRowHover(hostTrigger)
11564
11867
 
11868
+ // The host's footer entries are hidden in place on every pass, drawer or
11869
+ // no drawer: the host re-renders them, and a pass that skipped this left
11870
+ // their icons painted beside the account row until the drawer had been
11871
+ // opened once. Only the mirroring needs a container to write into.
11872
+ mirror.sync(footArea)
11873
+
11565
11874
  var root = surface.mode() === 'host' ? surface.container() : accountPopover
11566
11875
  if (root === null) return
11567
11876
  rows.syncHeader(root, hostTrigger)
11568
- mirror.sync(footArea)
11569
11877
  // The rail toggle (and any reflow) moves the anchor without a window
11570
11878
  // resize or a page scroll, so an open drawer re-resolves its position at
11571
11879
  // the end of its own pass.
@@ -12278,11 +12586,26 @@ window.__ModuleLoader__.load({
12278
12586
  if (sessions !== undefined && sessions !== null && typeof sessions.open === 'function') sessions.open(id)
12279
12587
  }
12280
12588
 
12589
+ /**
12590
+ * Delete one stored session through the host half's own route.
12591
+ *
12592
+ * The harness gives the browser half no deletion API of its own (the
12593
+ * workspace controller archives and unarchives; the agent protocol's
12594
+ * session delete is the host delegating to an ACP agent that owns the
12595
+ * storage), so the skin's host half removes the session's stored directory
12596
+ * and answers here. The row stays on any refusal — the host refuses a live
12597
+ * session, and the next read tells the truth.
12598
+ */
12281
12599
  function removeArchived(id) {
12282
- var registry = service('remote.workspaceRegistry')
12283
- if (registry === undefined || registry === null || typeof registry.deleteSession !== 'function') return
12284
- registry.deleteSession(id).then(function (result) {
12285
- if (!result || result.ok !== true) return
12600
+ fetch(SESSION_DELETE_ROUTE, {
12601
+ method: 'POST',
12602
+ credentials: 'same-origin',
12603
+ headers: { 'content-type': 'application/json' },
12604
+ body: JSON.stringify({ sessionId: id }),
12605
+ }).then(function (response) {
12606
+ return response.ok ? response.json() : null
12607
+ }).then(function (result) {
12608
+ if (result === null || result.ok !== true) return
12286
12609
  if (items !== null) items = items.filter(function (row) { return row.id !== id })
12287
12610
  renderList()
12288
12611
  }).catch(function () { /* the row stays; the next read tells the truth */ })
package/lib/index.js CHANGED
@@ -28,9 +28,9 @@
28
28
  * the module graph and the whole skin with it. The browser half falls back to
29
29
  * its defaults when either surface is missing.
30
30
  */
31
- import { readFileSync } from 'node:fs'
32
- import { userInfo } from 'node:os'
33
- import { dirname, join } from 'node:path'
31
+ import { readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
32
+ import { homedir, userInfo } from 'node:os'
33
+ import { dirname, join, resolve, sep } from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
35
 
36
36
  export const name = 'dsh-claude-style'
@@ -56,6 +56,22 @@ const FONT_FILES = {
56
56
  }
57
57
  /** One-shot host OS user route; the browser half caches the response. */
58
58
  const USERNAME_PATH = `${ROUTE_PREFIX}/username`
59
+ /**
60
+ * Session deletion.
61
+ *
62
+ * The harness gives the browser half no deletion API of its own: the workspace
63
+ * controller archives and unarchives, and the agent protocol's session delete is
64
+ * the host delegating to an ACP agent that owns the storage. The archived row's
65
+ * delete button therefore comes here, where the stored session directory under
66
+ * the harness home can be removed. The path is also spelled in
67
+ * src/constants.js (SESSION_DELETE_ROUTE) for the browser half; keep the two in
68
+ * step.
69
+ */
70
+ const SESSION_DELETE_PATH = `${ROUTE_PREFIX}/session-delete`
71
+ /** Session ids are the harness's own shape; anything else is refused before it reaches a path. */
72
+ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/
73
+ /** Largest deletion request body read; a real one carries one id. */
74
+ const DELETE_BODY_MAX = 4096
59
75
 
60
76
  /** The namespace an older host knows this plugin's preferences by. */
61
77
  const LEGACY_SETTINGS_NAMESPACE = 'claude-style'
@@ -269,6 +285,129 @@ function sendJson(res, status, payload) {
269
285
  res.end(body)
270
286
  }
271
287
 
288
+ /** The harness sessions root: `<DSH home>/sessions`, resolved the host's own way. */
289
+ function sessionsRoot(ctx) {
290
+ try {
291
+ const dshHomePath = ctx.get('dshHomePath')
292
+ if (typeof dshHomePath === 'function') return dshHomePath('sessions')
293
+ } catch { /* no home-path service: fall back to the environment */ }
294
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
295
+ return join(home, 'sessions')
296
+ }
297
+
298
+ /**
299
+ * Whether the host holds this session open right now.
300
+ *
301
+ * A live session's log is open and being appended to, so its directory must not
302
+ * be removed under the writer. When the live set cannot be read, the answer is
303
+ * "live": an unreadable set cannot authorize the deletion.
304
+ */
305
+ function sessionIsLive(ctx, sessionId) {
306
+ let sessions = null
307
+ try {
308
+ sessions = ctx.get('sessions')
309
+ } catch {
310
+ sessions = null
311
+ }
312
+ if (sessions === null || sessions === undefined) return false
313
+ try {
314
+ if (typeof sessions.get === 'function') {
315
+ const found = sessions.get(sessionId)
316
+ return found !== undefined && found !== null
317
+ }
318
+ if (typeof sessions.list === 'function') {
319
+ const listed = sessions.list()
320
+ if (Array.isArray(listed)) {
321
+ return listed.some((item) => (typeof item === 'string' ? item : item?.id ?? item?.sessionId) === sessionId)
322
+ }
323
+ }
324
+ } catch {
325
+ return true
326
+ }
327
+ return true
328
+ }
329
+
330
+ /** One bounded request body, or null when it is oversized or unreadable. */
331
+ function readRequestBody(req, limit) {
332
+ return new Promise((settle) => {
333
+ const chunks = []
334
+ let size = 0
335
+ req.on('data', (chunk) => {
336
+ size += chunk.length
337
+ if (size > limit) {
338
+ settle(null)
339
+ req.destroy()
340
+ return
341
+ }
342
+ chunks.push(chunk)
343
+ })
344
+ req.on('end', () => settle(Buffer.concat(chunks).toString('utf8')))
345
+ req.on('error', () => settle(null))
346
+ })
347
+ }
348
+
349
+ /**
350
+ * Delete one stored session: the directory named by the id under one of the
351
+ * sessions root's working-directory directories.
352
+ *
353
+ * The id never reaches a path unchecked — it must match the harness's own
354
+ * shape, the lookup is by exact name, and the resolved directory must stay
355
+ * inside the sessions root.
356
+ */
357
+ async function deleteSession(ctx, req, res) {
358
+ const refused = refusalOf(ctx, req)
359
+ if (refused !== undefined) {
360
+ sendJson(res, refused, { ok: false, error: refused === 401 ? 'unauthorized' : 'forbidden' })
361
+ return
362
+ }
363
+ const raw = await readRequestBody(req, DELETE_BODY_MAX)
364
+ let request = null
365
+ try {
366
+ request = raw === null ? null : JSON.parse(raw)
367
+ } catch {
368
+ request = null
369
+ }
370
+ const sessionId = request !== null && typeof request.sessionId === 'string' ? request.sessionId : ''
371
+ if (!SESSION_ID_RE.test(sessionId)) {
372
+ sendJson(res, 400, { ok: false, error: 'invalid session id' })
373
+ return
374
+ }
375
+ if (sessionIsLive(ctx, sessionId)) {
376
+ sendJson(res, 409, { ok: false, error: 'session is open' })
377
+ return
378
+ }
379
+ const root = resolve(sessionsRoot(ctx))
380
+ let dir = null
381
+ try {
382
+ for (const entry of readdirSync(root)) {
383
+ const candidate = join(root, entry, sessionId)
384
+ let stat
385
+ try {
386
+ stat = statSync(candidate)
387
+ } catch {
388
+ continue
389
+ }
390
+ if (stat.isDirectory()) {
391
+ dir = candidate
392
+ break
393
+ }
394
+ }
395
+ } catch {
396
+ dir = null
397
+ }
398
+ if (dir === null || !resolve(dir).startsWith(root + sep)) {
399
+ sendJson(res, 404, { ok: false, error: 'session not found' })
400
+ return
401
+ }
402
+ try {
403
+ rmSync(dir, { recursive: true, force: true })
404
+ } catch (error) {
405
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
406
+ return
407
+ }
408
+ sendJson(res, 200, { ok: true })
409
+ }
410
+
272
411
  /**
273
412
  * Register the plugin's host surfaces.
274
413
  * @param ctx - host plugin context.
@@ -379,6 +518,29 @@ export function apply(ctx) {
379
518
  warn(`username route unavailable: ${error?.message ?? error}`)
380
519
  }
381
520
 
521
+ try {
522
+ // The archived row's delete button. POST only: the browser half sends
523
+ // one id, and a GET must never reach the filesystem.
524
+ disposers.push(scope.webServer.register({
525
+ kind: 'exact',
526
+ path: SESSION_DELETE_PATH,
527
+ handler: (req, res) => {
528
+ if (req.method !== 'POST') {
529
+ res.writeHead(405, { allow: 'POST' })
530
+ res.end()
531
+ return
532
+ }
533
+ void deleteSession(ctx, req, res).catch((error) => {
534
+ try {
535
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
536
+ } catch { /* the response may already be gone */ }
537
+ })
538
+ },
539
+ }))
540
+ } catch (error) {
541
+ warn(`session delete route unavailable: ${error?.message ?? error}`)
542
+ }
543
+
382
544
  return () => {
383
545
  for (const dispose of disposers) {
384
546
  try {
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.6.0",
4
+ "version": "0.6.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "lib/claude-mark.svg",