See what a risky command would change before it runs.

Harnova is an independent open-source AI agent project based on DeepSeek Harness, developed by DeepSeek AI. It retains the upstream plugin runtime and package names, and uses the harnova command and its own data directory.
It is built on an everything-is-a-plugin architecture and powered by Cordis, whose design is described in _A Programming Paradigm for Spatiotemporal Composability_.
Upstream documentation: https://deepseek-harness.github.io/deepseek-harness/
Harnova-specific capabilities are development goals, not completed features:
Security Mode selects agent capabilities; SSH selects the execution environment. They remain independent. Extensions use plugin APIs where possible so upstream updates remain manageable. Harnova has its own branding and application identity; signed distribution still requires its own signing credentials and update service.
Harnova uses standard GitHub Actions runners for Linux, Windows, and macOS. Pull requests and changes to main run checks without provider API keys; live-provider E2E runs are started manually. Package builds also verify installation from local tarballs.
Distribution uses Harnova GitHub Releases. The manual release workflow prepares a draft with developer package archives. These archives retain the current upstream package names and use Harnova branding. Desktop installers require signing credentials and an update service before distribution. Linux packaging needs additional work.
Development instructions are in AGENTS.md. Chinese documentation copies and translation bookkeeping are removed; English reference documentation remains. Browser GIF recording is optional, using record-browser-gif. Product UI locale dictionaries are separate from documentation.
Review the safety notice before running the project.
To run from a repository checkout:
git clone https://github.com/Aero123421/Harnova.git
cd Harnova
pnpm install
pnpm run build
pnpm harnova web
Harnova starts the Web UI at http://127.0.0.1:3081 by default; use --no-open to suppress browser launch. Its user data lives in ~/.harnova, or the explicit HARNOVA_HOME directory. Inherited DSH_HOME and existing ~/.dsh data are ignored. See the coexistence audit.
pnpm run build prepares the repository artifacts. pnpm harnova web uses those built artifacts without rebuilding.
See CONTRIBUTING.md.
Start with the development guide and architecture documentation.
pnpm run dev:web builds, serves, and rebuilds client bundles on source edits in one terminal, and make help lists the matching Make targets for Web and Desktop; the guide's application commands section owns the full table.
For agents, follow AGENTS.md.
@misc{deepseek-harness2026,
title={DeepSeek Harness: Everything is a Plugin},
author={DeepSeek-AI},
year={2026},
publisher={GitHub},
howpublished={\url{https://github.com/deepseek-ai/deepseek-harness}},
}
Third-party dependencies and their licenses are disclosed in THIRD_PARTY_NOTICES.md.
hooks/blast-radius.mjs 128 lines1// 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 post publishes the tool.call hook below unchanged; 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) {
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(["sleep", "0.25"]); // 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