dsh-claude-style 0.6.0 → 0.6.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,22 @@
2
2
 
3
3
  All notable changes to `dsh-claude-style` are documented here, newest first.
4
4
 
5
+ ## [0.6.1] - 2026-09-24
6
+
7
+ [中文](#cn-0.6.1) | [English](#en-0.6.1)
8
+
9
+ <h3 id="cn-0.6.1">新增功能</h3>
10
+
11
+ - **适配 DSH 0.1.7 内置自动审查(Auto review)**:分段控件与权限菜单增加审查模式,分段控件显示为 `Auto`、弹层与当前状态显示为 `Auto review`;原 `Auto`(完全权限)更名为 `Yolo`;选择审查模式或完全权限时均正常调起宿主风险确认对话框。审查档位跟随宿主的权限目录:只有宿主的 `permissionPresets` 目录携带 `auto`(内置自动审查已启用)时才显示,目录变化时随之增减;目录读不到时整个权限控件交还宿主的访问模式按钮。
12
+ - **已归档会话的删除按钮恢复可用**:宿主没有给浏览器半边提供删除会话的接口(工作区控制器只有归档与取消归档,agent 协议里的会话删除由宿主委托给持有存储的 ACP agent),插件此前调用第三方插件的路由,该插件不在环境里时点下去没有任何反应。现在由插件的宿主半边新增私有路由 `POST /dsh-claude-style/session-delete` 删除本机会话目录:只接受 POST 与同源请求,id 必须匹配宿主自身的会话 id 形状,正在打开的会话拒绝删除,目录解析后必须留在会话根目录内。
13
+
14
+ <h3 id="en-0.6.1">New Features</h3>
15
+
16
+ - **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.
17
+ - **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.
18
+
19
+ **Full Changelog**: [v0.6.0...v0.6.1](https://github.com/Nwflower/dsh-claude-style/compare/v0.6.0...v0.6.1)
20
+
5
21
  ## [0.6.0] - 2026-09-24
6
22
 
7
23
  [中文](#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
  /**
@@ -2988,7 +3005,7 @@ window.__ModuleLoader__.load({
2988
3005
  "/* --- 2.4 Custom Components (Segments & Account Popover) --- */",
2989
3006
  "/* ---------- Claude Code layout: permission segments ---------- */",
2990
3007
  "/* 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",
3008
+ " a Read | Edit | Auto | Yolo segmented control over presets — for",
2992
3009
  " as long as the permission control is installed to stand in for it. */",
2993
3010
  "body[data-dsh-claude-style][data-dsh-claude-composer-active][data-dsh-claude-permissions] button[aria-label^=\"访问模式\"],",
2994
3011
  "body[data-dsh-claude-style][data-dsh-claude-composer-active][data-dsh-claude-permissions] button[aria-label^=\"Access mode\"] {",
@@ -3039,6 +3056,14 @@ window.__ModuleLoader__.load({
3039
3056
  " box-shadow: 0 1px 2px rgba(20, 20, 19, 0.08);",
3040
3057
  "}",
3041
3058
  "",
3059
+ "/* Rows whose preset the host's permission catalog does not carry (the Auto",
3060
+ " review tier without the auto-review integration) are taken out of the",
3061
+ " control entirely. */",
3062
+ "body[data-dsh-claude-style] .dsh-claude-segment[data-hidden],",
3063
+ "body[data-dsh-claude-style] .dsh-claude-perm-popover .dsh-claude-popover-item[data-hidden] {",
3064
+ " display: none !important;",
3065
+ "}",
3066
+ "",
3042
3067
  "/* ---------- Claude Code layout: in-conversation permission popover ---------- */",
3043
3068
  "body[data-dsh-claude-style] .dsh-claude-perm-container {",
3044
3069
  " position: relative !important;",
@@ -7015,6 +7040,134 @@ window.__ModuleLoader__.load({
7015
7040
  var permPopover = null
7016
7041
  var permHoverIntent = null
7017
7042
 
7043
+ /**
7044
+ * Whether the host's permission catalog carries the live Auto review
7045
+ * preset (`auto`, registered by the auto-review plugin): true or false
7046
+ * once a catalog read settled, null until then. While unknown the rows
7047
+ * stay hidden, the way the shipped picker renders nothing before its
7048
+ * first catalog read.
7049
+ */
7050
+ var autoPresetLive = null
7051
+ /** The first catalog failure; thrown on the next sync to retire the feature. */
7052
+ var autoPresetError = null
7053
+ var catalogFiber = null
7054
+ var catalogChangedDisposer = null
7055
+
7056
+ /** The Auto review rows are only offered while the host's catalog carries the preset. */
7057
+ function rowVisible(preset) {
7058
+ return preset !== AUTO_REVIEW_PRESET || autoPresetLive === true
7059
+ }
7060
+
7061
+ /**
7062
+ * Mirror the availability onto a row as data-hidden; the stylesheet turns
7063
+ * that into display:none (an inline style would lose to the popover
7064
+ * item's own `display: flex !important`).
7065
+ */
7066
+ function syncRowVisibility(row, preset) {
7067
+ if (rowVisible(preset)) {
7068
+ if (row.hasAttribute('data-hidden')) row.removeAttribute('data-hidden')
7069
+ } else if (!row.hasAttribute('data-hidden')) {
7070
+ row.setAttribute('data-hidden', '')
7071
+ }
7072
+ }
7073
+
7074
+ /**
7075
+ * Read the host's permission catalog and record whether it carries the
7076
+ * live Auto review preset. The shipped picker builds its rows from the
7077
+ * same catalog, so it is the authority on what is switchable. A rejected
7078
+ * read retries like the account profile's reads (the client connection
7079
+ * may still be coming up at install); once the retries are exhausted the
7080
+ * failure is remembered and thrown on the next sync, which retires this
7081
+ * feature and hands the shipped access button back (D12).
7082
+ */
7083
+ var AUTO_PRESET_RETRY_MS = [1000, 5000]
7084
+ var autoPresetRetries = 0
7085
+ var autoPresetRead = 0
7086
+ var autoPresetRetry = null
7087
+
7088
+ /** Invalidate the read in flight and drop any retry still waiting. */
7089
+ function dropAutoPresetRead() {
7090
+ autoPresetRead++
7091
+ if (autoPresetRetry !== null) {
7092
+ clearTimeout(autoPresetRetry)
7093
+ autoPresetRetry = null
7094
+ }
7095
+ }
7096
+
7097
+ function probeAutoPreset() {
7098
+ var namespace = null
7099
+ try { namespace = ctx.get('remote.permissionPresets') } catch (error) { namespace = null }
7100
+ if (namespace === null || namespace === void 0 || typeof namespace.catalog !== 'function') {
7101
+ if (typeof ctx.inject !== 'function') {
7102
+ autoPresetError = new Error('permission: the host exposes no remote.permissionPresets catalog')
7103
+ ui.schedule()
7104
+ }
7105
+ return
7106
+ }
7107
+ dropAutoPresetRead()
7108
+ var read = autoPresetRead
7109
+ namespace.catalog().then(function (result) {
7110
+ if (read !== autoPresetRead) return
7111
+ if (result === null || typeof result !== 'object' || result.ok !== true ||
7112
+ result.value === null || typeof result.value !== 'object' ||
7113
+ !Array.isArray(result.value.options)) {
7114
+ autoPresetError = new Error('permission: unexpected permissionPresets catalog shape')
7115
+ ui.schedule()
7116
+ return
7117
+ }
7118
+ autoPresetRetries = 0
7119
+ autoPresetError = null
7120
+ var live = false
7121
+ for (var i = 0; i < result.value.options.length; i++) {
7122
+ var option = result.value.options[i]
7123
+ if (option !== null && typeof option === 'object' && option.value === AUTO_REVIEW_PRESET) {
7124
+ live = true
7125
+ break
7126
+ }
7127
+ }
7128
+ autoPresetLive = live
7129
+ ui.schedule()
7130
+ }, function () {
7131
+ if (read !== autoPresetRead) return
7132
+ if (autoPresetRetries >= AUTO_PRESET_RETRY_MS.length) {
7133
+ autoPresetError = new Error('permission: the permissionPresets catalog read failed ' + (AUTO_PRESET_RETRY_MS.length + 1) + ' times')
7134
+ ui.schedule()
7135
+ return
7136
+ }
7137
+ autoPresetRetry = setTimeout(function () {
7138
+ autoPresetRetry = null
7139
+ probeAutoPreset()
7140
+ }, AUTO_PRESET_RETRY_MS[autoPresetRetries++])
7141
+ })
7142
+ }
7143
+
7144
+ /**
7145
+ * The namespace may register after this plugin (the host registers each
7146
+ * remote namespace as its package loads), so wait for it the way the
7147
+ * account row waits for remote.account; a host without inject gets one
7148
+ * read now. The catalog-changed event re-reads when the auto-review
7149
+ * integration is registered or dropped while the page stays open.
7150
+ */
7151
+ function startAutoPresetProbe() {
7152
+ if (typeof ctx.inject === 'function') {
7153
+ catalogFiber = ctx.inject(['remote.permissionPresets'], function (scope) {
7154
+ scope.effect(function () {
7155
+ probeAutoPreset()
7156
+ return function () {}
7157
+ }, 'dsh-claude-style: permission catalog')
7158
+ })
7159
+ } else {
7160
+ probeAutoPreset()
7161
+ }
7162
+ var remoteRoot = null
7163
+ try { remoteRoot = ctx.get('remote') } catch (error) { remoteRoot = null }
7164
+ if (remoteRoot !== null && remoteRoot !== void 0 && typeof remoteRoot.$on === 'function') {
7165
+ catalogChangedDisposer = remoteRoot.$on('permission-presets/catalog-changed', function () {
7166
+ probeAutoPreset()
7167
+ })
7168
+ }
7169
+ }
7170
+
7018
7171
  function buildSegments(onPick) {
7019
7172
  var group = document.createElement('div')
7020
7173
  group.className = SEGMENTS_CLASS
@@ -7183,7 +7336,9 @@ window.__ModuleLoader__.load({
7183
7336
 
7184
7337
  function updatePermState(preset) {
7185
7338
  if (!permLabel || !permPopover) return
7186
- var matchedLabel = 'Accept edits'
7339
+ // A preset our option list does not carry (the host's `custom`, or a
7340
+ // row this host's catalog does not offer) shows its machine value.
7341
+ var matchedLabel = preset === null ? 'Accept edits' : preset
7187
7342
  for (var i = 0; i < PERMISSION_OPTIONS.length; i++) {
7188
7343
  if (PERMISSION_OPTIONS[i].preset === preset) {
7189
7344
  matchedLabel = PERMISSION_OPTIONS[i].label
@@ -7198,7 +7353,8 @@ window.__ModuleLoader__.load({
7198
7353
  var items = permPopover.querySelectorAll('[data-preset]')
7199
7354
  for (var j = 0; j < items.length; j++) {
7200
7355
  var it = items[j]
7201
- var isCurrent = it.getAttribute('data-preset') === preset
7356
+ var rowPreset = it.getAttribute('data-preset')
7357
+ var isCurrent = rowPreset === preset
7202
7358
  var check = it.querySelector('.dsh-claude-perm-check')
7203
7359
  if (check) {
7204
7360
  check.style.display = isCurrent ? 'inline' : 'none'
@@ -7208,6 +7364,7 @@ window.__ModuleLoader__.load({
7208
7364
  } else {
7209
7365
  it.removeAttribute('data-active')
7210
7366
  }
7367
+ syncRowVisibility(it, rowPreset)
7211
7368
  }
7212
7369
  }
7213
7370
 
@@ -7219,29 +7376,42 @@ window.__ModuleLoader__.load({
7219
7376
  }
7220
7377
 
7221
7378
  /**
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
7379
+ * Drive the shipped access menu to its risk-gated row (Full access or Auto review),
7380
+ * so the switch runs through the shipped risk-confirmation dialog rather than a
7224
7381
  * skin-owned prompt. The trigger is hidden by this skin but still in
7225
7382
  * the tree, so a synthetic click still opens the menu; the row is then
7226
7383
  * picked by its label. A plain confirm stands in only when that menu
7227
7384
  * cannot be reached, which keeps the switch behind an explicit
7228
7385
  * acknowledgement either way.
7229
7386
  */
7230
- function openShippedGate() {
7387
+ var gateInFlight = false
7388
+
7389
+ function openShippedGate(preset) {
7390
+ if (gateInFlight) return
7391
+ var isAuto = preset === AUTO_REVIEW_PRESET
7392
+ var targetLabels = isAuto ? AUTO_REVIEW_LABELS : FULL_ACCESS_LABELS
7393
+ var prompt = isAuto ? AUTO_REVIEW_PROMPT : GATED_PROMPT
7231
7394
  var trigger = findAccessTrigger()
7232
7395
  if (trigger === null) {
7233
- if (window.confirm(GATED_PROMPT)) submitPreset(GATED_PRESET)
7396
+ if (window.confirm(prompt)) submitPreset(preset)
7234
7397
  return
7235
7398
  }
7399
+ gateInFlight = true
7236
7400
  trigger.click()
7237
7401
  var attempts = 0
7238
7402
  function seek() {
7239
- var items = document.querySelectorAll('[role="menu"] button[role="menuitem"]')
7403
+ // The skin's own popover is itself a role=menu whose row texts start
7404
+ // with the same labels, so it must stay out of this search — matching
7405
+ // it would click back into pick() and toggle the shipped menu every
7406
+ // frame. The shipped Auto review row concatenates its EXP badge into
7407
+ // the text ("Auto reviewEXP"), so the label matches as a prefix.
7408
+ var items = document.querySelectorAll('[role="menu"]:not(.dsh-claude-perm-popover) button[role="menuitem"]')
7240
7409
  for (var i = 0; i < items.length; i++) {
7241
7410
  var text = (items[i].textContent || '').trim()
7242
- for (var j = 0; j < FULL_ACCESS_LABELS.length; j++) {
7243
- if (text === FULL_ACCESS_LABELS[j]) {
7411
+ for (var j = 0; j < targetLabels.length; j++) {
7412
+ if (text.indexOf(targetLabels[j]) === 0) {
7244
7413
  items[i].click()
7414
+ gateInFlight = false
7245
7415
  return
7246
7416
  }
7247
7417
  }
@@ -7251,8 +7421,9 @@ window.__ModuleLoader__.load({
7251
7421
  requestAnimationFrame(seek)
7252
7422
  return
7253
7423
  }
7424
+ gateInFlight = false
7254
7425
  trigger.click()
7255
- if (window.confirm(GATED_PROMPT)) submitPreset(GATED_PRESET)
7426
+ if (window.confirm(prompt)) submitPreset(preset)
7256
7427
  }
7257
7428
  requestAnimationFrame(seek)
7258
7429
  }
@@ -7261,8 +7432,8 @@ window.__ModuleLoader__.load({
7261
7432
  var session = currentSession(ctx)
7262
7433
  if (session === null || preset === null) return
7263
7434
  if (preset === currentPreset(session)) return
7264
- if (preset === GATED_PRESET) {
7265
- openShippedGate()
7435
+ if (preset === GATED_PRESET || preset === AUTO_REVIEW_PRESET) {
7436
+ openShippedGate(preset)
7266
7437
  return
7267
7438
  }
7268
7439
  submitPreset(preset)
@@ -7327,6 +7498,7 @@ window.__ModuleLoader__.load({
7327
7498
  }
7328
7499
  for (var j = 0; j < segments.children.length; j++) {
7329
7500
  var item = segments.children[j]
7501
+ syncRowVisibility(item, item.getAttribute('data-preset'))
7330
7502
  if (item.getAttribute('data-preset') === preset) {
7331
7503
  item.setAttribute('data-active', '')
7332
7504
  item.setAttribute('aria-checked', 'true')
@@ -7366,6 +7538,7 @@ window.__ModuleLoader__.load({
7366
7538
 
7367
7539
  ui.permissions = {
7368
7540
  sync: function () {
7541
+ if (autoPresetError !== null) throw autoPresetError
7369
7542
  stats.sync()
7370
7543
  syncSegments()
7371
7544
  },
@@ -7383,10 +7556,20 @@ window.__ModuleLoader__.load({
7383
7556
  // The composer restyle hides the host's access button and statistics
7384
7557
  // dialogs only while this says their replacement is installed.
7385
7558
  document.body.setAttribute(PERMISSIONS_ATTR, '')
7559
+ startAutoPresetProbe()
7386
7560
 
7387
7561
  return function () {
7388
7562
  stats.teardown()
7389
7563
  if (permHoverIntent) permHoverIntent.cancel()
7564
+ dropAutoPresetRead()
7565
+ if (catalogFiber !== null && typeof catalogFiber.dispose === 'function') {
7566
+ try { catalogFiber.dispose() } catch (error) { /* the fiber may already be gone */ }
7567
+ catalogFiber = null
7568
+ }
7569
+ if (catalogChangedDisposer !== null) {
7570
+ catalogChangedDisposer()
7571
+ catalogChangedDisposer = null
7572
+ }
7390
7573
  if (permDocPointerListener) {
7391
7574
  document.removeEventListener('pointerdown', permDocPointerListener)
7392
7575
  permDocPointerListener = null
@@ -7398,6 +7581,7 @@ window.__ModuleLoader__.load({
7398
7581
  }
7399
7582
  removeStrayNodes(document, '.' + SEGMENTS_CLASS + '[data-composer-segments], .dsh-claude-perm-container', [])
7400
7583
  segments = null
7584
+ gateInFlight = false
7401
7585
  if (permPopover !== null && permPopover.parentElement !== null) permPopover.parentElement.removeChild(permPopover)
7402
7586
  permPopover = null
7403
7587
  permBtn = null
@@ -12278,11 +12462,26 @@ window.__ModuleLoader__.load({
12278
12462
  if (sessions !== undefined && sessions !== null && typeof sessions.open === 'function') sessions.open(id)
12279
12463
  }
12280
12464
 
12465
+ /**
12466
+ * Delete one stored session through the host half's own route.
12467
+ *
12468
+ * The harness gives the browser half no deletion API of its own (the
12469
+ * workspace controller archives and unarchives; the agent protocol's
12470
+ * session delete is the host delegating to an ACP agent that owns the
12471
+ * storage), so the skin's host half removes the session's stored directory
12472
+ * and answers here. The row stays on any refusal — the host refuses a live
12473
+ * session, and the next read tells the truth.
12474
+ */
12281
12475
  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
12476
+ fetch(SESSION_DELETE_ROUTE, {
12477
+ method: 'POST',
12478
+ credentials: 'same-origin',
12479
+ headers: { 'content-type': 'application/json' },
12480
+ body: JSON.stringify({ sessionId: id }),
12481
+ }).then(function (response) {
12482
+ return response.ok ? response.json() : null
12483
+ }).then(function (result) {
12484
+ if (result === null || result.ok !== true) return
12286
12485
  if (items !== null) items = items.filter(function (row) { return row.id !== id })
12287
12486
  renderList()
12288
12487
  }).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.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "lib/claude-mark.svg",