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 +16 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/architecture.md +1 -1
- package/lib/client.js +221 -22
- package/lib/index.js +165 -3
- package/package.json +1 -1
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 (
|
|
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. **输入框** ——
|
|
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°(四重对称图形,转回原位);底部账户抽屉、封号彩蛋一并保留。
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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: '
|
|
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
|
|
230
|
+
/** The presets the shipped UI gates behind its risk-confirmation dialog. */
|
|
225
231
|
var GATED_PRESET = 'danger-full-access'
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 <
|
|
7243
|
-
if (text
|
|
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(
|
|
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
|
-
|
|
12283
|
-
|
|
12284
|
-
|
|
12285
|
-
|
|
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.
|
|
4
|
+
"version": "0.6.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"icon": "lib/claude-mark.svg",
|