SLOPSHOPPER

blast-radius

See what a risky command would change before it runs.

newpanebandguardprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ Blast Radius ✕ › fix the failing auth test and add an audit log call │ ┃ Blast Radius: rm -rf build && git push │ ┃ --force origin main ⏺ Read(src/auth.ts) │ ┃ Would delete 3 file(s) () under build. ⎿ Read 6 lines │ ┃ package.json ⏺ Update(src/auth.ts) │ ┃ README.md ⎿ Added 2 lines, removed 1 line │ ┃ src ⏺ Bash(bun test) │ ┃ [ Proceed ] [ Cancel ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius
Blast Radius: rm -rf build && git push --force origin main Would delete 3 file(s) () under build. package.json README.md src [ Proceed ] [ Cancel ]
README

<img src="client/public/dshub-mark.png" width="88" alt="DSHub 蓝鲸标志" />

DSHub

你的 AI 伙伴与跨端工作空间

连接模型、文件、插件与自动化,用自然语言管理项目、协作与日常事务。

A cross-platform AI workspace, built on DeepSeek Harness and Lynx.

阶段:预览原型 Harness 0.2.1-rc.3 Lynx 宿主 Node.js 24.9+

界面预览 · 快速开始 · 功能与进度 · 开发文档 · 作者

当前为预览原型。 已有可运行的网页、Linux 调试窗口及本地 Android 预览 APK;完整跨平台能力、真实模型与真机端到端仍在推进。下文区分已经实现的界面、已执行的检查和产品目标。

界面预览

桌面端 · 保留 Harness 原版体验

原版侧栏、会话、模型、插件与主题;深色模式使用原版白色鲸鱼图标,默认进入对话页。

DSHub 桌面深色预览:原版侧栏、工作区与对话输入框

移动端 · 聊天、伙伴与功能菜单

<table> <tr> <th align="center">聊天</th> <th align="center">AI 伙伴通讯录</th> <th align="center">侧栏菜单</th> </tr> <tr> <td align="center"><img src="docs/assets/previews/mobile-chat.png" width="250" alt="移动端深色聊天与一体式输入框" /></td> <td align="center"><img src="docs/assets/previews/mobile-contacts.png" width="250" alt="移动端浅色 AI 伙伴通讯录" /></td> <td align="center"><img src="docs/assets/previews/mobile-sidebar.png" width="250" alt="移动端插件、自动化、工作区和日程侧栏" /></td> </tr> </table>

<sub>截图来自独立本机演示环境。通讯录使用专门创建的演示预设,不包含真实账号、私人会话或模型回复;预览图不代表真机验收。截图说明见 预览资源。</sub>

功能与进度

模块当前实现验证边界
原版对话官方 Harness 会话、模型设置、插件及工作区独立服务与浏览器验证;未以此宣称真实模型通过
移动聊天聊天 / 通讯录切换、真实预设伙伴、一体式输入框与抽屉菜单浏览器适配已验证;键盘、安全区及后台行为待真机验收
AI 群聊选择多个伙伴,独立官方会话,支持彼此 @ 与有限轮次接力会话创建与路由测试通过;真实模型群聊待验收
临时聊天独立临时实例,点击直接进入,结束后停止并清理目录本机启动与删除检查;手机端清理尚未验收
附件与语音拍照 / 相册 / 文件入口、按住转写、上滑取消与草稿保留Android 桥已构建;识别测试使用模拟结果
日程与自动化全部任务列表和月历视图,连接原版任务目录界面与导航已检查;真实定时任务另行验收
个人微信独立本机连接器、授权入口、问答与日报配置授权、真实收发及模型端到端尚未完成验收

桌面端沿用 Harness 原版界面。移动端首页不显示底部 Dock,侧栏顺序为 插件 → 自动化任务 → 工作区 → 日程 → 查看更多;账号、模型和 Agent 预设统一从「我的」进入。

跨平台分为两组:Linux / macOS / Windows 共用桌面设计,Android / iOS / 鸿蒙共用移动设计。该分组是产品约定,不表示六个平台均已支持或完成验收。

快速开始

1. 启动网页预览

需要 Node.js 24.9+。在仓库根目录运行:

npm ci --prefix runtime
npm ci --prefix client
npm run build --prefix client
npm run build:preview --prefix client
npm run preview:static --prefix client

打开 http://127.0.0.1:5182/,移动布局可使用 http://127.0.0.1:5182/?simulate=mobile。

宿主通过 iframe 加载官方网页,原生宿主使用 WebView。账号、模型和插件在官方页面配置;切换宿主页面保留同一网页实例。预览服务管理项目独立的 DSH 后端,不加载已有用户的全局 DSH 账号与会话。

2. 打开 Linux 桌面窗口

需要本机 GTK / WebKitGTK 构建环境,具体依赖见 客户端说明。

npm run build:linux --prefix client
npm run open:linux --prefix client

桌面窗口使用独立官方运行时,保留原版侧栏,不添加 Dock,也不默认打开画布。支持 Ctrl+R / F5 跳过缓存刷新。

同时对照移动端和桌面端:

npm run open:dual --prefix client

双窗口排列脚本目前使用本机 niri 窗口管理器,详见 桌面界面说明。

3. Android 本地预览 APK

当前 APK 基于固定版本的 Lynx Explorer 4.1.2 构建,首页和 DSHub 主宿主包均内置。原版网页与模型仍依赖电脑上的 DSH 服务及 USB 转发。

python3 client/scripts/build-android-preview.py \
  --source-apk /path/LynxExplorer-4.1.2.apk \
  --apktool /path/apktool_2.12.1.jar

输出为 .runtime/android-container/dshub-preview.apk,独立包名 com.dshub.preview。源码 APK 按 SHA-256 校验,构建需要 Java 与 Android SDK 35;输入文件、SDK 路径和 USB 打开方式见 客户端说明。

APK 已完成本地构建、签名、对齐及内置资源检查;本次尚未完成手机安装验收。APK 与本机签名不提交到 Git 仓库,仓库当前没有承诺可直接下载的正式发布包。

迁入源码已完成本机完整构建,并有单独的试运行入口:

npm run preview:source --prefix client

打开 http://127.0.0.1:5212/ 。源码版后端为 5213,使用 .runtime/harness-source-state/;与默认 npm 版预览相互独立。首次使用须按 源码接入记录 完成构建。

工程结构

目录用途
client/Lynx 宿主、浏览器预览、移动适配、原生桥与本机连接器
runtime/锁定官方 npm @deepseek-ai/dsh@0.2.1-rc.3,作为默认运行入口
packages/ · apps/ · native/ · vendor/按模块归位的 Harness 与原生相关资源
sdk/python/ · tests/harness/ · sites/harness-docs/上游 SDK、测试与文档站资源
client/design/harness/图标、深浅色变量、字体、基础组件样式与来源记录
docs/产品、技术、接口、数据模型、测试与实施文档
tools/harness/迁移清单、SHA-256 校验、源码重建与构建工具
.runtime/本机服务、账号数据、构建产物与备份;已忽略

已归位 19,188 个 Harness 版本受控文件。默认 npm 入口和迁入源码分别管理,不宣称二者字节相同。上游参考版本固定为 22ddee3792402c3e70ec5f86a63c3e3e895a2f60;原始参考 checkout 不随仓库上传。

开发文档

从这里开始深入了解
产品需求 PRD · 技术设计 TDD · 工作分解 WBS规格 TSD · 架构 HLD · 详细设计 LLD
文档索引 · 贡献指南 · 变更记录OpenAPI · 数据模型 ERD · 需求追踪 RTM
移动聊天适配 · AI 群聊输入交互规范 · 远程连接
测试计划 · 可行性台账资源对照 · 模块索引

PRD 与 WBS 描述产品目标和实施计划;其中的计划、文档合同通过和本机原型结果,均不等同于产品或真机验收完成。

验证

基础开发检查:

npm run typecheck --prefix client
npm test --prefix client
npm run build --prefix client
npm run build:preview --prefix client
PREVIEW_URL=http://127.0.0.1:5182 npm run test:browser --prefix client

文档依赖安装和合同检查见 贡献指南。资源校验:

python3 tools/harness/migrate.py verify

浏览器用例中的部分测试需要指定运行环境、独立服务或可选依赖;请结合对应测试文件运行。本机截图及详细取证位于被忽略的 client/artifacts/,不是仓库中的 CI 通过徽章。真实模型、语音服务、微信收发与真机安装结果另行记录。

数据与安全

  • 项目独立运行根:使用 .runtime/ 保存本机配置、会话、工作区和连接器状态;不载入全局用户 DSH 数据。
  • 认证沿用原版:启动认证入口只留在服务内存,通过原版流程交换 Cookie;日志脱敏,不将 token、Cookie 或模型凭据写入源码和构建产物。
  • 敏感操作显式开启:微信问答、日报和其他连接能力遵循实际授权状态;不伪造已连接、已发送或已送达。
  • 模型能力如实展示:未配置模型、权限不足或服务失败时保留真实错误,不提供模拟聊天回复。

漏洞与安全问题请按 SECURITY.md 反馈,不要在公开 Issue 中粘贴密钥、认证头或私人会话。

作者

Wang-Yang-source · DSHub 作者与维护者

项目主页 · 反馈问题 · 参与贡献

欢迎通过 Issue 反馈使用体验,或通过 Pull Request 参与文档、界面与工程改进。

致谢与许可

DSHub 基于 DeepSeek Harness 与 Lynx 构建,复用其官方界面、插件机制和宿主能力。迁入源码及依赖保留各自的许可证与来源记录,Harness 许可证见 第三方许可。

DSHub 鲸鱼图像由项目提供方提供,原图与透明版保存在 client/public/;本仓库不对该图像另行授予许可。桌面欢迎区使用 Harness 原版单色 SVG,移动端使用项目彩色鲸鱼素材。

Source 1 files
hooks/blast-radius.mjs 128 lines
1// Blast Radius, from "Getting started with Claude Code mods"
2// (https://claude.dev/blog/getting-started-with-claude-code-mods/, Anthropic, 2026-10-01).
3// The tool.call hook adds a waitArgv option for DSH's portable timer; the rest of the module
4// (classify, measure, the pane and band trees) is completed to the post's
5// description of the mod, which is marked where it starts.
6
7// Blast Radius: see what a risky command would change before it runs.
8
9// —— completed to the post's description (not in the published excerpt) ——
10
11const RISKS = [
12  { pattern: /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b/, kind: "delete", what: "delete files recursively" },
13  { pattern: /\bgit\s+reset\s+--hard\b/, kind: "reset", what: "discard uncommitted changes" },
14  { pattern: /\bgit\s+clean\b/, kind: "clean", what: "delete untracked files" },
15  { pattern: /\bgit\s+push\b[^\n]*\s(--force|-f)\b/, kind: "force-push", what: "rewrite a remote branch" },
16  { pattern: /\b(migrate|manage\.py\s+migrate|prisma\s+migrate|rails\s+db:migrate)\b/, kind: "migrate", what: "change the database schema" },
17];
18
19// What the hook is holding: one command at a time, until a button decides.
20let held = null;
21
22export function register(on, { waitArgv = ["sleep", "0.25"] } = {}) {
23  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
24    const risk = classify(String(e.command ?? ""));
25    if (risk === null) return next(e);                 // everything else runs as normal
26
27    const report = await measure($, risk, await $.session.cwd());  // git status, git clean -n, du, ...
28    held = { command: e.command, risk, report, decision: null };
29    const opened = await $.ui.open({ id: "blast-radius", title: "Blast Radius", focus: true });
30    if (!opened.isPlaced) held.where = "band";         // too narrow for a pane: draw above the prompt
31
32    while (held.decision === null && !next.signal.aborted) {
33      await $.process.run(waitArgv);                   // time inside $ calls doesn't count against the hook's time limit
34    }
35    if (held.decision === "proceed") return next(e);   // let it run
36    return { deny: `Blast Radius held this command: the user pressed Cancel. It would have: ${report.summary}.` };
37  });
38
39  // —— completed to the post's description (not in the published excerpt) ——
40
41  on("ui.render", { component: "Pane" }, ($, e, next) => {
42    if (e.requestId !== "blast-radius" || held === null || held.decision !== null) return next(e);
43    return report($, e);
44  });
45
46  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
47    if (held === null || held.decision !== null || held.where !== "band") return next(e);
48    return report($, e);
49  });
50}
51
52function classify(command) {
53  const risk = RISKS.find((r) => r.pattern.test(command));
54  return risk ? { kind: risk.kind, what: risk.what, command } : null;
55}
56
57async function measure($, risk, cwd) {
58  const lines = [];
59  let summary = `${risk.what} in ${cwd}`;
60  if (risk.kind === "delete") {
61    const target = risk.command.match(/\brm\s+-\S+\s+(\S+)/)?.[1];
62    const du = await run($, ["du", "-sh", target ?? "."]);
63    const size = du.stdout.trim().split(/\s+/)[0] ?? "?";
64    const count = await run($, ["find", target ?? ".", "-type", "f"]);
65    const files = count.stdout.split("\n").filter(Boolean).length;
66    summary = `delete ${files} file(s) (${size}) under ${target ?? cwd}`;
67    lines.push(...count.stdout.split("\n").filter(Boolean).slice(0, 9));
68  } else if (risk.kind === "reset") {
69    const status = await run($, ["git", "status", "--porcelain"]);
70    const changed = status.stdout.split("\n").filter(Boolean);
71    summary = `discard uncommitted changes to ${changed.length} file(s)`;
72    lines.push(...changed.slice(0, 9));
73  } else if (risk.kind === "clean") {
74    const dry = await run($, ["git", "clean", "-n"]);
75    const removed = dry.stdout.split("\n").filter(Boolean);
76    summary = `delete ${removed.length} untracked path(s)`;
77    lines.push(...removed.slice(0, 9));
78  } else if (risk.kind === "force-push") {
79    const ahead = await run($, ["git", "log", "--oneline", "HEAD..origin/main"]);
80    const commits = ahead.stdout.split("\n").filter(Boolean);
81    summary = `rewrite the remote branch, dropping ${commits.length} commit(s) not on this branch`;
82    lines.push(...commits.slice(0, 9));
83  } else if (risk.kind === "migrate") {
84    const pending = await run($, ["sh", "-c", "python manage.py showmigrations --plan 2>/dev/null | grep '\\[ \\]' || true"]);
85    const migrations = pending.stdout.split("\n").filter(Boolean);
86    summary = `apply ${migrations.length} pending migration(s)`;
87    lines.push(...migrations.slice(0, 9));
88  }
89  return { summary, lines };
90}
91
92// A dry run that fails (no git, no python, no such path) still lets the user decide.
93async function run($, argv) {
94  try {
95    return await $.process.run(argv);
96  } catch {
97    return { exitCode: 1, stdout: "", stderr: "" };
98  }
99}
100
101function decide(decision) {
102  if (held !== null) held.decision = decision;
103}
104
105function report($, e) {
106  const { Box, Text, Button } = $.ui.resolve(e);
107  const detail = held.report.lines.map((line) => Text({ dimColor: true, children: `  ${line}` }));
108  return Box({
109    flexDirection: "column",
110    border: true,
111    borderColor: "yellow",
112    paddingX: 1,
113    children: [
114      Text({ color: "yellow", bold: true, children: `Blast Radius: ${held.command}` }),
115      Text({ children: `Would ${held.report.summary}.` }),
116      ...detail,
117      Box({
118        flexDirection: "row",
119        gap: 2,
120        children: [
121          Button({ label: "Proceed", hotkey: "1", onPress: () => decide("proceed") }),
122          Button({ label: "Cancel", hotkey: "2", onPress: () => decide("cancel") }),
123        ],
124      }),
125    ],
126  });
127}
128